Skip to main content

optirs_gpu/shaders/
mod.rs

1//! Compute-shader sources for the GPU optimizer steps, in WGSL and MSL.
2//!
3//! # Why these shaders exist instead of `scirs2_core`'s registry kernels
4//!
5//! `scirs2-core` 0.6.5 registers `adam_optimizer`, `sgd_optimizer`,
6//! `rmsprop_optimizer`, `adagrad_optimizer` and `lamb_optimizer` with real WGSL
7//! bodies, but they are not drivable-as-correct through the public API:
8//!
9//! * every hyper-parameter lives in a multi-field `var<uniform>` block, and the
10//!   wgpu backend packs those scalars by iterating a
11//!   `HashMap<String, KernelParam>` (`gpu/backends/wgpu.rs`,
12//!   `create_bind_group_from_params`). The byte order of the resulting uniform
13//!   buffer is therefore the map's iteration order — effectively random per
14//!   process — and there is no public `set_bytes` to bypass it;
15//! * their `metal_source` is empty, so a Metal context resolves them to an
16//!   empty shader.
17//!
18//! The shaders here carry every scalar in a **storage buffer** bound by name,
19//! which is deterministic on both backends, and are compiled through
20//! [`scirs2_core::gpu::GpuCompiler::compile`] (real `naga` validation on wgpu,
21//! a real `MTLLibrary` on Metal).
22//!
23//! # The buffer naming convention
24//!
25//! The Metal backend binds buffers to argument-table indices by looking their
26//! names up in the fixed list `["x", "y", "a", "b", "result", "output"]` and
27//! only then falls back to a non-deterministic hash-map order. Every kernel
28//! below therefore uses **only** those six names, in that order, so the binding
29//! indices are fully determined. The wgpu backend binds by name against the
30//! WGSL declarations, so the same names work there unchanged.
31//!
32//! The per-kernel meaning of each name is documented on each source constant.
33//!
34//! # Scalar packing convention
35//!
36//! Every kernel takes a hyper-parameter buffer of `f32`. Integer fields are
37//! carried through it bit-for-bit (`bitcast<u32>` in WGSL, `as_type<uint>` in
38//! MSL) so element counts above 2^24 stay exact.
39
40pub mod msl;
41pub mod wgsl;
42
43use scirs2_core::gpu::GpuBackend;
44
45/// Threads per workgroup / threadgroup used by every optimizer kernel.
46///
47/// The Metal backend in scirs2-core hard-codes a 256-wide threadgroup, so this
48/// value is not free to change.
49pub const WORKGROUP_SIZE: usize = 256;
50
51/// The optimizer kernels this crate ships.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53pub enum OptimizerKernel {
54    /// Adam with coupled L2 weight decay.
55    Adam,
56    /// AdamW with decoupled weight decay.
57    AdamW,
58    /// SGD with optional momentum / Nesterov.
59    Sgd,
60    /// RMSprop with optional centering and momentum.
61    Rmsprop,
62    /// Adagrad with learning-rate decay.
63    Adagrad,
64    /// LAMB (two-phase, one pipeline).
65    Lamb,
66}
67
68impl OptimizerKernel {
69    /// Stable identifier, used to key the compiled-pipeline cache.
70    pub fn id(self) -> &'static str {
71        match self {
72            Self::Adam => "adam",
73            Self::AdamW => "adamw",
74            Self::Sgd => "sgd",
75            Self::Rmsprop => "rmsprop",
76            Self::Adagrad => "adagrad",
77            Self::Lamb => "lamb",
78        }
79    }
80
81    /// Shader source for `backend`, or `None` when that backend has no source.
82    pub fn source_for(self, backend: GpuBackend) -> Option<&'static str> {
83        match backend {
84            GpuBackend::Wgpu => Some(match self {
85                Self::Adam => wgsl::ADAM,
86                Self::AdamW => wgsl::ADAMW,
87                Self::Sgd => wgsl::SGD,
88                Self::Rmsprop => wgsl::RMSPROP,
89                Self::Adagrad => wgsl::ADAGRAD,
90                Self::Lamb => wgsl::LAMB,
91            }),
92            GpuBackend::Metal => Some(match self {
93                Self::Adam => msl::ADAM,
94                Self::AdamW => msl::ADAMW,
95                Self::Sgd => msl::SGD,
96                Self::Rmsprop => msl::RMSPROP,
97                Self::Adagrad => msl::ADAGRAD,
98                Self::Lamb => msl::LAMB,
99            }),
100            _ => None,
101        }
102    }
103
104    /// Cache key combining the kernel and the backend it was compiled for.
105    pub fn cache_key(self, backend: GpuBackend) -> &'static str {
106        // Backends never mix within one context, so the kernel id alone is a
107        // sufficient key; this indirection exists so the invariant is explicit.
108        let _ = backend;
109        self.id()
110    }
111}
112
113/// Kernels used by [`crate::multi_gpu`] for collective (cross-device)
114/// operations. Kept as a sibling to [`OptimizerKernel`] rather than folded
115/// into it: a reduction is not an optimizer step, and giving it its own type
116/// keeps that honest at the API level.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
118pub enum CollectiveKernel {
119    /// Divide a local buffer by the replica count — the finishing step of a
120    /// sum-then-average all-reduce. See [`wgsl::ALL_REDUCE_MEAN`].
121    AllReduceMean,
122}
123
124impl CollectiveKernel {
125    /// Stable identifier, used to key the compiled-pipeline cache.
126    pub fn id(self) -> &'static str {
127        match self {
128            Self::AllReduceMean => "all_reduce_mean",
129        }
130    }
131
132    /// Shader source for `backend`, or `None` when that backend has no source.
133    pub fn source_for(self, backend: GpuBackend) -> Option<&'static str> {
134        match backend {
135            GpuBackend::Wgpu => Some(match self {
136                Self::AllReduceMean => wgsl::ALL_REDUCE_MEAN,
137            }),
138            GpuBackend::Metal => Some(match self {
139                Self::AllReduceMean => msl::ALL_REDUCE_MEAN,
140            }),
141            _ => None,
142        }
143    }
144
145    /// Cache key combining the kernel and the backend it was compiled for.
146    pub fn cache_key(self, backend: GpuBackend) -> &'static str {
147        let _ = backend;
148        self.id()
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    const ALL: [OptimizerKernel; 6] = [
157        OptimizerKernel::Adam,
158        OptimizerKernel::AdamW,
159        OptimizerKernel::Sgd,
160        OptimizerKernel::Rmsprop,
161        OptimizerKernel::Adagrad,
162        OptimizerKernel::Lamb,
163    ];
164
165    /// The Metal backend extracts the entry point with
166    /// `source.find("kernel void ")` up to the next `(`, so the name and the
167    /// opening paren must sit on one line.
168    #[test]
169    fn msl_entry_points_are_extractable() {
170        for kernel in ALL {
171            let source = kernel
172                .source_for(GpuBackend::Metal)
173                .expect("every kernel has MSL");
174            let start = source
175                .find("kernel void ")
176                .expect("MSL source declares a kernel");
177            let rest = &source[start + "kernel void ".len()..];
178            let paren = rest.find('(').expect("entry point is followed by '('");
179            let name = &rest[..paren];
180            assert!(
181                !name.contains('\n'),
182                "{}: entry point spans lines",
183                kernel.id()
184            );
185            assert!(
186                !name.trim().is_empty(),
187                "{}: empty entry point",
188                kernel.id()
189            );
190        }
191    }
192
193    /// scirs2-core's WGSL reflection parser is line-oriented: `@group`,
194    /// `@binding` and the `var<...>` declaration must share a line, read-only
195    /// storage must be spelled exactly `var<storage, read>`, and `@compute`
196    /// must sit on the same line as `fn main(`.
197    #[test]
198    fn wgsl_matches_the_reflection_parser() {
199        for kernel in ALL {
200            let source = kernel
201                .source_for(GpuBackend::Wgpu)
202                .expect("every kernel has WGSL");
203            let id = kernel.id();
204
205            assert!(
206                !source.contains("var<uniform>"),
207                "{id}: uniform blocks are packed in non-deterministic order"
208            );
209            assert!(
210                !source.contains("var<storage,read"),
211                "{id}: `var<storage,read>` is not recognised; use `var<storage, read>`"
212            );
213
214            let mut entry_lines = 0;
215            for line in source.lines() {
216                let trimmed = line.trim();
217                if trimmed.contains("@compute") {
218                    entry_lines += 1;
219                    assert!(
220                        trimmed.contains("fn main("),
221                        "{id}: @compute must share its line with `fn main(`"
222                    );
223                }
224                if trimmed.contains("@binding(") {
225                    assert!(
226                        trimmed.contains("@group(0)"),
227                        "{id}: every binding must be in @group(0)"
228                    );
229                    assert!(
230                        trimmed.contains("var<"),
231                        "{id}: binding attributes must share the declaration line"
232                    );
233                }
234            }
235            assert_eq!(entry_lines, 1, "{id}: expected exactly one entry point");
236        }
237    }
238
239    /// Only the six names the Metal argument-table mapping understands may be
240    /// used, otherwise binding indices become hash-map dependent.
241    #[test]
242    fn only_deterministic_buffer_names_are_used() {
243        const ALLOWED: [&str; 6] = ["x", "y", "a", "b", "result", "output"];
244        for kernel in ALL {
245            let source = kernel
246                .source_for(GpuBackend::Wgpu)
247                .expect("every kernel has WGSL");
248            for line in source.lines() {
249                let trimmed = line.trim();
250                if !trimmed.contains("@binding(") {
251                    continue;
252                }
253                let after = trimmed
254                    .split_once('>')
255                    .map(|(_, rest)| rest)
256                    .unwrap_or_default();
257                let name = after
258                    .split_once(':')
259                    .map(|(n, _)| n.trim())
260                    .unwrap_or_default();
261                assert!(
262                    ALLOWED.contains(&name),
263                    "{}: buffer name {name:?} is not in the deterministic set {ALLOWED:?}",
264                    kernel.id()
265                );
266            }
267        }
268    }
269
270    #[test]
271    fn unsupported_backends_have_no_source() {
272        assert!(OptimizerKernel::Adam.source_for(GpuBackend::Cpu).is_none());
273        assert!(OptimizerKernel::Adam.source_for(GpuBackend::Cuda).is_none());
274        assert!(OptimizerKernel::Adam
275            .source_for(GpuBackend::OpenCL)
276            .is_none());
277    }
278
279    const COLLECTIVE_ALL: [CollectiveKernel; 1] = [CollectiveKernel::AllReduceMean];
280
281    #[test]
282    fn collective_msl_entry_points_are_extractable() {
283        for kernel in COLLECTIVE_ALL {
284            let source = kernel
285                .source_for(GpuBackend::Metal)
286                .expect("every collective kernel has MSL");
287            let start = source
288                .find("kernel void ")
289                .expect("MSL source declares a kernel");
290            let rest = &source[start + "kernel void ".len()..];
291            let paren = rest.find('(').expect("entry point is followed by '('");
292            let name = &rest[..paren];
293            assert!(
294                !name.contains('\n'),
295                "{}: entry point spans lines",
296                kernel.id()
297            );
298            assert!(
299                !name.trim().is_empty(),
300                "{}: empty entry point",
301                kernel.id()
302            );
303        }
304    }
305
306    #[test]
307    fn collective_wgsl_matches_the_reflection_parser() {
308        for kernel in COLLECTIVE_ALL {
309            let source = kernel
310                .source_for(GpuBackend::Wgpu)
311                .expect("every collective kernel has WGSL");
312            let id = kernel.id();
313
314            assert!(
315                !source.contains("var<uniform>"),
316                "{id}: uniform blocks are packed in non-deterministic order"
317            );
318            assert!(
319                !source.contains("var<storage,read"),
320                "{id}: `var<storage,read>` is not recognised; use `var<storage, read>`"
321            );
322
323            let mut entry_lines = 0;
324            for line in source.lines() {
325                let trimmed = line.trim();
326                if trimmed.contains("@compute") {
327                    entry_lines += 1;
328                    assert!(
329                        trimmed.contains("fn main("),
330                        "{id}: @compute must share its line with `fn main(`"
331                    );
332                }
333                if trimmed.contains("@binding(") {
334                    assert!(
335                        trimmed.contains("@group(0)"),
336                        "{id}: every binding must be in @group(0)"
337                    );
338                    assert!(
339                        trimmed.contains("var<"),
340                        "{id}: binding attributes must share the declaration line"
341                    );
342                }
343            }
344            assert_eq!(entry_lines, 1, "{id}: expected exactly one entry point");
345        }
346    }
347
348    #[test]
349    fn collective_only_deterministic_buffer_names_are_used() {
350        const ALLOWED: [&str; 6] = ["x", "y", "a", "b", "result", "output"];
351        for kernel in COLLECTIVE_ALL {
352            let source = kernel
353                .source_for(GpuBackend::Wgpu)
354                .expect("every collective kernel has WGSL");
355            for line in source.lines() {
356                let trimmed = line.trim();
357                if !trimmed.contains("@binding(") {
358                    continue;
359                }
360                let after = trimmed
361                    .split_once('>')
362                    .map(|(_, rest)| rest)
363                    .unwrap_or_default();
364                let name = after
365                    .split_once(':')
366                    .map(|(n, _)| n.trim())
367                    .unwrap_or_default();
368                assert!(
369                    ALLOWED.contains(&name),
370                    "{}: buffer name {name:?} is not in the deterministic set {ALLOWED:?}",
371                    kernel.id()
372                );
373            }
374        }
375    }
376
377    #[test]
378    fn collective_unsupported_backends_have_no_source() {
379        assert!(CollectiveKernel::AllReduceMean
380            .source_for(GpuBackend::Cpu)
381            .is_none());
382        assert!(CollectiveKernel::AllReduceMean
383            .source_for(GpuBackend::Cuda)
384            .is_none());
385        assert!(CollectiveKernel::AllReduceMean
386            .source_for(GpuBackend::OpenCL)
387            .is_none());
388    }
389}