Skip to main content

optirs_gpu/
optimizers.rs

1//! GPU-resident optimizer steps executed through `scirs2_core::gpu`.
2//!
3//! Every optimizer here implements [`crate::GpuOptimizer`] for `f32` and runs a
4//! real compute shader: parameters and gradients are uploaded to device
5//! buffers, a WGSL kernel from [`crate::shaders::wgsl`] is dispatched, and the updated
6//! parameters are read back. The per-parameter optimizer state (Adam's `m`/`v`,
7//! SGD's momentum buffer, ...) stays resident in device memory between steps;
8//! [`crate::GpuOptimizer::move_to_cpu`] genuinely downloads it and
9//! [`crate::GpuOptimizer::move_to_gpu`] genuinely uploads it again.
10//!
11//! # Backend support
12//!
13//! Only the WebGPU backend (`wgpu` feature → Vulkan / Metal / DX12) has a
14//! complete compute path in scirs2-core 0.6.x. Constructing an optimizer with
15//! any other backend returns [`GpuOptimError::UnsupportedOperation`] rather
16//! than silently computing nothing — in particular the Metal backend registers
17//! *empty* kernel sources for the optimizer kernels, and CUDA was removed from
18//! scirs2-core in 0.6.x.
19//!
20//! # Precision
21//!
22//! WGSL compute shaders are `f32`. The trait is therefore implemented for
23//! `f32` only; there is no `f64` GPU path and none is faked.
24
25use scirs2_core::gpu::{GpuBackend, GpuBuffer, GpuContext, GpuKernelHandle};
26use scirs2_core::ndarray::{Array, Dimension};
27
28use crate::shaders::{OptimizerKernel, WORKGROUP_SIZE};
29use crate::{GpuOptimError, GpuOptimizer};
30
31/// Configuration shared by every GPU optimizer.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub struct GpuOptimizerConfig {
34    /// Backend to use, or `None` to probe the supported backends in order.
35    ///
36    /// Auto-selection tries [`GpuBackend::Wgpu`] first (portable: Vulkan /
37    /// Metal / DX12) and falls back to [`GpuBackend::Metal`] on macOS.
38    pub backend: Option<GpuBackend>,
39}
40
41impl GpuOptimizerConfig {
42    /// Pin the optimizer to one backend.
43    pub fn with_backend(backend: GpuBackend) -> Self {
44        Self {
45            backend: Some(backend),
46        }
47    }
48}
49
50/// Backends that can execute the optimizer kernels, in preference order.
51///
52/// `Cuda` and `Rocm` are absent because `scirs2-core` 0.6.x has no working
53/// compute path for them; `OpenCL` is absent because this crate ships no
54/// OpenCL C sources. Asking for any of those is an explicit error rather than
55/// a silent no-op.
56pub const SUPPORTED_BACKENDS: [GpuBackend; 2] = [GpuBackend::Wgpu, GpuBackend::Metal];
57
58/// Round `n` up to a whole number of workgroups of [`WORKGROUP_SIZE`].
59fn workgroup_count(n: usize) -> Result<u32, GpuOptimError> {
60    let groups = n.div_ceil(WORKGROUP_SIZE);
61    u32::try_from(groups).map_err(|_| {
62        GpuOptimError::UnsupportedOperation(format!(
63            "{n} elements need {groups} workgroups, which exceeds the u32 dispatch limit"
64        ))
65    })
66}
67
68/// Encode a `usize` element count into an `f32` slot bit-for-bit.
69///
70/// The kernels recover it with `bitcast<u32>` / `as_type<uint>`, so counts
71/// above 2^24 stay exact (a plain `n as f32` would not).
72fn encode_u32(value: usize) -> Result<f32, GpuOptimError> {
73    let raw = u32::try_from(value).map_err(|_| {
74        GpuOptimError::UnsupportedOperation(format!("{value} does not fit in a u32 kernel operand"))
75    })?;
76    Ok(f32::from_bits(raw))
77}
78
79/// Owns the GPU context plus the pipeline it currently holds.
80///
81/// `GpuCompiler::compile` on the WebGPU backend of scirs2-core 0.6.5 registers
82/// every compiled shader under one fixed internal name per context, so a
83/// context can only have one live pipeline. This cache makes that explicit: a
84/// kernel is recompiled only when a *different* shader is requested on the same
85/// context, and the handle is always resolved immediately before the dispatch
86/// that uses it.
87struct KernelCache {
88    context: GpuContext,
89    backend: GpuBackend,
90    installed: Option<&'static str>,
91    handle: Option<GpuKernelHandle>,
92}
93
94impl KernelCache {
95    fn new(requested: Option<GpuBackend>) -> Result<Self, GpuOptimError> {
96        match requested {
97            Some(backend) => {
98                if !SUPPORTED_BACKENDS.contains(&backend) {
99                    return Err(GpuOptimError::UnsupportedOperation(format!(
100                        "backend {backend} cannot run optirs-gpu optimizer kernels; \
101                         supported backends are {SUPPORTED_BACKENDS:?}"
102                    )));
103                }
104                let context = GpuContext::new(backend)?;
105                Ok(Self {
106                    context,
107                    backend,
108                    installed: None,
109                    handle: None,
110                })
111            }
112            None => {
113                let mut reasons = Vec::new();
114                for backend in SUPPORTED_BACKENDS {
115                    match GpuContext::new(backend) {
116                        Ok(context) => {
117                            return Ok(Self {
118                                context,
119                                backend,
120                                installed: None,
121                                handle: None,
122                            })
123                        }
124                        Err(e) => reasons.push(format!("{backend}: {e}")),
125                    }
126                }
127                Err(GpuOptimError::UnsupportedOperation(format!(
128                    "no GPU backend available for optimizer kernels ({})",
129                    reasons.join("; ")
130                )))
131            }
132        }
133    }
134
135    fn context(&self) -> &GpuContext {
136        &self.context
137    }
138
139    fn backend(&self) -> GpuBackend {
140        self.backend
141    }
142
143    /// Return a handle for `kernel`, compiling it if it is not the pipeline
144    /// currently installed on this context.
145    fn kernel(&mut self, kernel: OptimizerKernel) -> Result<&GpuKernelHandle, GpuOptimError> {
146        let key = kernel.cache_key(self.backend);
147        if self.installed != Some(key) || self.handle.is_none() {
148            let source = kernel.source_for(self.backend).ok_or_else(|| {
149                GpuOptimError::UnsupportedOperation(format!(
150                    "no {} shader source for backend {}",
151                    kernel.id(),
152                    self.backend
153                ))
154            })?;
155            let handle = self.context.execute(|compiler| compiler.compile(source))?;
156            self.handle = Some(handle);
157            self.installed = Some(key);
158        }
159        self.handle.as_ref().ok_or_else(|| {
160            GpuOptimError::InvalidState("kernel compilation produced no handle".into())
161        })
162    }
163}
164
165/// Device-side per-parameter state buffers plus their host mirror.
166///
167/// The host mirror is authoritative while the optimizer is on the CPU; the
168/// device buffers are authoritative while it is on the GPU. Exactly one of the
169/// two is live at a time, and transitions copy the data for real.
170struct StateBuffers {
171    /// Number of independent state vectors (2 for Adam: `m` and `v`).
172    slots: usize,
173    /// Element count of each state vector.
174    len: usize,
175    host: Vec<Vec<f32>>,
176    device: Vec<GpuBuffer<f32>>,
177}
178
179impl StateBuffers {
180    fn new(slots: usize, len: usize) -> Self {
181        Self {
182            slots,
183            len,
184            host: vec![vec![0.0f32; len]; slots],
185            device: Vec::new(),
186        }
187    }
188
189    fn is_resident(&self) -> bool {
190        self.device.len() == self.slots
191    }
192
193    /// Upload the host mirror into fresh device buffers.
194    fn upload(&mut self, context: &GpuContext) -> Result<(), GpuOptimError> {
195        if self.len == 0 {
196            return Err(GpuOptimError::InvalidState(
197                "cannot allocate zero-length optimizer state".into(),
198            ));
199        }
200        let mut device = Vec::with_capacity(self.slots);
201        for slot in &self.host {
202            let buffer = context.create_buffer::<f32>(self.len);
203            buffer.copy_from_host(slot)?;
204            device.push(buffer);
205        }
206        self.device = device;
207        Ok(())
208    }
209
210    /// Download the device buffers back into the host mirror and release them.
211    fn download(&mut self) -> Result<(), GpuOptimError> {
212        if !self.is_resident() {
213            return Ok(());
214        }
215        for (slot, buffer) in self.host.iter_mut().zip(self.device.iter()) {
216            buffer.copy_to_host(slot)?;
217        }
218        self.device.clear();
219        Ok(())
220    }
221
222    fn device_slot(&self, index: usize) -> Result<&GpuBuffer<f32>, GpuOptimError> {
223        self.device.get(index).ok_or(GpuOptimError::NotInitialized)
224    }
225
226    /// Reset both mirrors to zero for a new parameter length.
227    fn resize(&mut self, len: usize) {
228        self.len = len;
229        self.host = vec![vec![0.0f32; len]; self.slots];
230        self.device.clear();
231    }
232}
233
234/// Shared plumbing for the concrete optimizers below.
235struct GpuStepEngine {
236    cache: KernelCache,
237    state: StateBuffers,
238    on_gpu: bool,
239    step_count: u64,
240}
241
242impl GpuStepEngine {
243    fn new(config: GpuOptimizerConfig, slots: usize) -> Result<Self, GpuOptimError> {
244        Ok(Self {
245            cache: KernelCache::new(config.backend)?,
246            state: StateBuffers::new(slots, 0),
247            on_gpu: false,
248            step_count: 0,
249        })
250    }
251
252    fn backend(&self) -> GpuBackend {
253        self.cache.backend()
254    }
255
256    fn move_to_gpu(&mut self) -> Result<(), GpuOptimError> {
257        if self.on_gpu {
258            return Ok(());
259        }
260        if self.state.len > 0 {
261            let context = &self.cache.context;
262            self.state.upload(context)?;
263        }
264        self.on_gpu = true;
265        Ok(())
266    }
267
268    fn move_to_cpu(&mut self) -> Result<(), GpuOptimError> {
269        if !self.on_gpu {
270            return Ok(());
271        }
272        self.state.download()?;
273        self.on_gpu = false;
274        Ok(())
275    }
276
277    /// Make sure the device-side state matches `len` elements and is resident.
278    fn prepare(&mut self, len: usize) -> Result<(), GpuOptimError> {
279        if !self.on_gpu {
280            return Err(GpuOptimError::InvalidState(
281                "optimizer is on the CPU; call move_to_gpu() before step_gpu()".into(),
282            ));
283        }
284        if len == 0 {
285            return Err(GpuOptimError::InvalidState(
286                "cannot run a GPU step on an empty parameter array".into(),
287            ));
288        }
289        // scirs2-core's Metal buffers silently clamp allocations at 1 GiB and
290        // then `assert!` in `copy_from_host` when the requested copy exceeds the
291        // clamped size. Reject oversized parameter vectors here with a real
292        // error instead of tripping that assert.
293        const MAX_BUFFER_BYTES: usize = 1024 * 1024 * 1024;
294        let bytes = len.saturating_mul(std::mem::size_of::<f32>());
295        if bytes > MAX_BUFFER_BYTES {
296            return Err(GpuOptimError::UnsupportedOperation(format!(
297                "{len} f32 parameters need {bytes} bytes, above the {MAX_BUFFER_BYTES}-byte \
298                 per-buffer limit of the GPU backends this crate supports"
299            )));
300        }
301        if self.state.len != len {
302            self.state.resize(len);
303            self.step_count = 0;
304        }
305        if !self.state.is_resident() {
306            let context = &self.cache.context;
307            self.state.upload(context)?;
308        }
309        Ok(())
310    }
311}
312
313/// Flatten an array of any layout into a contiguous host vector.
314fn to_host_vec<D: Dimension>(array: &Array<f32, D>) -> Vec<f32> {
315    match array.as_slice() {
316        Some(slice) => slice.to_vec(),
317        None => array.iter().copied().collect(),
318    }
319}
320
321/// Write a contiguous host vector back into an array of any layout.
322fn from_host_vec<D: Dimension>(
323    array: &mut Array<f32, D>,
324    values: &[f32],
325) -> Result<(), GpuOptimError> {
326    if values.len() != array.len() {
327        return Err(GpuOptimError::DimensionMismatch {
328            expected: array.shape().to_vec(),
329            actual: vec![values.len()],
330        });
331    }
332    for (dst, src) in array.iter_mut().zip(values.iter()) {
333        *dst = *src;
334    }
335    Ok(())
336}
337
338/// Validate that parameters and gradients agree in shape.
339fn check_shapes<D: Dimension>(
340    params: &Array<f32, D>,
341    gradients: &Array<f32, D>,
342) -> Result<(), GpuOptimError> {
343    if params.shape() != gradients.shape() {
344        return Err(GpuOptimError::DimensionMismatch {
345            expected: params.shape().to_vec(),
346            actual: gradients.shape().to_vec(),
347        });
348    }
349    Ok(())
350}
351
352// ─── Adam / AdamW ───────────────────────────────────────────────────────────
353
354/// Hyper-parameters shared by [`GpuAdam`] and [`GpuAdamW`].
355#[derive(Debug, Clone, Copy, PartialEq)]
356pub struct AdamParams {
357    /// Step size.
358    pub learning_rate: f32,
359    /// First-moment decay.
360    pub beta1: f32,
361    /// Second-moment decay.
362    pub beta2: f32,
363    /// Denominator stabiliser.
364    pub epsilon: f32,
365    /// Weight decay (coupled L2 for Adam, decoupled for AdamW).
366    pub weight_decay: f32,
367}
368
369impl Default for AdamParams {
370    fn default() -> Self {
371        Self {
372            learning_rate: 1e-3,
373            beta1: 0.9,
374            beta2: 0.999,
375            epsilon: 1e-8,
376            weight_decay: 0.0,
377        }
378    }
379}
380
381impl AdamParams {
382    /// Build a validated parameter set.
383    ///
384    /// Returns [`GpuOptimError::InvalidState`] instead of panicking when a
385    /// value is outside its admissible range.
386    pub fn new(
387        learning_rate: f32,
388        beta1: f32,
389        beta2: f32,
390        epsilon: f32,
391        weight_decay: f32,
392    ) -> Result<Self, GpuOptimError> {
393        let params = Self {
394            learning_rate,
395            beta1,
396            beta2,
397            epsilon,
398            weight_decay,
399        };
400        params.validate()?;
401        Ok(params)
402    }
403
404    fn validate(&self) -> Result<(), GpuOptimError> {
405        let invalid = |what: &str| GpuOptimError::InvalidState(format!("invalid Adam {what}"));
406        if !(self.learning_rate.is_finite() && self.learning_rate > 0.0) {
407            return Err(invalid("learning rate (must be finite and > 0)"));
408        }
409        if !(self.beta1.is_finite() && (0.0..1.0).contains(&self.beta1)) {
410            return Err(invalid("beta1 (must be in [0, 1))"));
411        }
412        if !(self.beta2.is_finite() && (0.0..1.0).contains(&self.beta2)) {
413            return Err(invalid("beta2 (must be in [0, 1))"));
414        }
415        if !(self.epsilon.is_finite() && self.epsilon > 0.0) {
416            return Err(invalid("epsilon (must be finite and > 0)"));
417        }
418        if !(self.weight_decay.is_finite() && self.weight_decay >= 0.0) {
419            return Err(invalid("weight decay (must be finite and >= 0)"));
420        }
421        Ok(())
422    }
423
424    /// Bias-correction denominators for a 1-based timestep.
425    fn bias_corrections(&self, step: u64) -> (f32, f32) {
426        let exp = step.min(i32::MAX as u64) as i32;
427        (1.0 - self.beta1.powi(exp), 1.0 - self.beta2.powi(exp))
428    }
429}
430
431macro_rules! adam_family {
432    ($name:ident, $kernel:expr, $doc:literal) => {
433        #[doc = $doc]
434        pub struct $name {
435            engine: GpuStepEngine,
436            params: AdamParams,
437        }
438
439        impl $name {
440            /// Create the optimizer with the default WebGPU backend.
441            pub fn new(params: AdamParams) -> Result<Self, GpuOptimError> {
442                Self::with_config(params, GpuOptimizerConfig::default())
443            }
444
445            /// Create the optimizer on an explicit backend.
446            pub fn with_config(
447                params: AdamParams,
448                config: GpuOptimizerConfig,
449            ) -> Result<Self, GpuOptimError> {
450                params.validate()?;
451                Ok(Self {
452                    engine: GpuStepEngine::new(config, 2)?,
453                    params,
454                })
455            }
456
457            /// Backend actually in use.
458            pub fn backend(&self) -> GpuBackend {
459                self.engine.backend()
460            }
461
462            /// Upload the optimizer state to device memory.
463            ///
464            /// Inherent alias for [`GpuOptimizer::move_to_gpu`] so callers do
465            /// not need a turbofish to pin the unused dimension parameter.
466            pub fn move_to_gpu(&mut self) -> Result<(), GpuOptimError> {
467                self.engine.move_to_gpu()
468            }
469
470            /// Download the optimizer state back to host memory.
471            ///
472            /// Inherent alias for [`GpuOptimizer::move_to_cpu`]; see
473            /// [`Self::move_to_gpu`].
474            pub fn move_to_cpu(&mut self) -> Result<(), GpuOptimError> {
475                self.engine.move_to_cpu()
476            }
477
478            /// Deprecated alias for [`Self::move_to_gpu`].
479            #[deprecated(since = "0.3.2", note = "renamed to `move_to_gpu`")]
480            pub fn to_gpu(&mut self) -> Result<(), GpuOptimError> {
481                self.move_to_gpu()
482            }
483
484            /// Deprecated alias for [`Self::move_to_cpu`].
485            #[deprecated(since = "0.3.2", note = "renamed to `move_to_cpu`")]
486            pub fn to_cpu(&mut self) -> Result<(), GpuOptimError> {
487                self.move_to_cpu()
488            }
489
490            /// Whether a real device (not the CPU fallback) is backing this optimizer.
491            pub fn is_gpu_available(&self) -> bool {
492                self.engine.backend() != GpuBackend::Cpu
493            }
494
495            /// Number of updates applied so far (1-based bias correction input).
496            pub fn step_count(&self) -> u64 {
497                self.engine.step_count
498            }
499
500            /// Current hyper-parameters.
501            pub fn params(&self) -> &AdamParams {
502                &self.params
503            }
504
505            /// Replace the hyper-parameters, validating them first.
506            pub fn set_params(&mut self, params: AdamParams) -> Result<(), GpuOptimError> {
507                params.validate()?;
508                self.params = params;
509                Ok(())
510            }
511        }
512
513        impl<D: Dimension> GpuOptimizer<f32, D> for $name {
514            fn is_gpu_available(&self) -> bool {
515                self.engine.backend() != GpuBackend::Cpu
516            }
517
518            fn move_to_gpu(&mut self) -> Result<(), GpuOptimError> {
519                self.engine.move_to_gpu()
520            }
521
522            fn move_to_cpu(&mut self) -> Result<(), GpuOptimError> {
523                self.engine.move_to_cpu()
524            }
525
526            fn step_gpu(
527                &mut self,
528                params: &mut Array<f32, D>,
529                gradients: &Array<f32, D>,
530            ) -> Result<(), GpuOptimError> {
531                check_shapes(params, gradients)?;
532                let n = params.len();
533                self.engine.prepare(n)?;
534                self.engine.step_count = self.engine.step_count.saturating_add(1);
535
536                let (bc1, bc2) = self.params.bias_corrections(self.engine.step_count);
537                let hyper = [
538                    self.params.learning_rate,
539                    self.params.beta1,
540                    self.params.beta2,
541                    self.params.epsilon,
542                    self.params.weight_decay,
543                    bc1,
544                    bc2,
545                    encode_u32(n)?,
546                ];
547
548                let host_params = to_host_vec(params);
549                let host_grads = to_host_vec(gradients);
550                let groups = workgroup_count(n)?;
551
552                let updated = {
553                    let context = self.engine.cache.context();
554                    let params_buf = context.create_buffer::<f32>(n);
555                    params_buf.copy_from_host(&host_params)?;
556                    let grads_buf = context.create_buffer::<f32>(n);
557                    grads_buf.copy_from_host(&host_grads)?;
558                    let hyper_buf = context.create_buffer::<f32>(hyper.len());
559                    hyper_buf.copy_from_host(&hyper)?;
560
561                    let m_buf = self.engine.state.device_slot(0)?.clone();
562                    let v_buf = self.engine.state.device_slot(1)?.clone();
563
564                    let kernel = self.engine.cache.kernel($kernel)?;
565                    kernel.set_buffer("x", &params_buf);
566                    kernel.set_buffer("y", &grads_buf);
567                    kernel.set_buffer("a", &m_buf);
568                    kernel.set_buffer("b", &v_buf);
569                    kernel.set_buffer("result", &hyper_buf);
570                    kernel.dispatch([groups, 1, 1]);
571
572                    let mut out = vec![0.0f32; n];
573                    params_buf.copy_to_host(&mut out)?;
574                    out
575                };
576
577                from_host_vec(params, &updated)
578            }
579        }
580    };
581}
582
583adam_family!(
584    GpuAdam,
585    OptimizerKernel::Adam,
586    "GPU Adam with coupled L2 weight decay, numerically matching \
587     `optirs_core::optimizers::Adam`."
588);
589adam_family!(
590    GpuAdamW,
591    OptimizerKernel::AdamW,
592    "GPU AdamW with *decoupled* weight decay: the decay term is applied to the \
593     parameter and never enters the moment estimates."
594);
595
596// ─── SGD ────────────────────────────────────────────────────────────────────
597
598/// Hyper-parameters for [`GpuSgd`].
599#[derive(Debug, Clone, Copy, PartialEq)]
600pub struct SgdParams {
601    /// Step size.
602    pub learning_rate: f32,
603    /// Momentum factor (0 disables the momentum buffer).
604    pub momentum: f32,
605    /// Dampening applied to the gradient inside the momentum buffer.
606    pub dampening: f32,
607    /// Coupled L2 weight decay.
608    pub weight_decay: f32,
609    /// Use Nesterov accelerated gradient.
610    pub nesterov: bool,
611}
612
613impl Default for SgdParams {
614    fn default() -> Self {
615        Self {
616            learning_rate: 1e-2,
617            momentum: 0.0,
618            dampening: 0.0,
619            weight_decay: 0.0,
620            nesterov: false,
621        }
622    }
623}
624
625impl SgdParams {
626    fn validate(&self) -> Result<(), GpuOptimError> {
627        let invalid = |what: &str| GpuOptimError::InvalidState(format!("invalid SGD {what}"));
628        if !(self.learning_rate.is_finite() && self.learning_rate > 0.0) {
629            return Err(invalid("learning rate (must be finite and > 0)"));
630        }
631        if !(self.momentum.is_finite() && self.momentum >= 0.0) {
632            return Err(invalid("momentum (must be finite and >= 0)"));
633        }
634        if !(self.dampening.is_finite() && (0.0..1.0).contains(&self.dampening)) {
635            return Err(invalid("dampening (must be in [0, 1))"));
636        }
637        if !(self.weight_decay.is_finite() && self.weight_decay >= 0.0) {
638            return Err(invalid("weight decay (must be finite and >= 0)"));
639        }
640        if self.nesterov && (self.momentum <= 0.0 || self.dampening != 0.0) {
641            return Err(invalid(
642                "Nesterov mode (requires momentum > 0 and dampening == 0)",
643            ));
644        }
645        Ok(())
646    }
647}
648
649/// GPU stochastic gradient descent with optional momentum / Nesterov.
650pub struct GpuSgd {
651    engine: GpuStepEngine,
652    params: SgdParams,
653}
654
655impl GpuSgd {
656    /// Create the optimizer with the default WebGPU backend.
657    pub fn new(params: SgdParams) -> Result<Self, GpuOptimError> {
658        Self::with_config(params, GpuOptimizerConfig::default())
659    }
660
661    /// Create the optimizer on an explicit backend.
662    pub fn with_config(
663        params: SgdParams,
664        config: GpuOptimizerConfig,
665    ) -> Result<Self, GpuOptimError> {
666        params.validate()?;
667        Ok(Self {
668            engine: GpuStepEngine::new(config, 1)?,
669            params,
670        })
671    }
672
673    /// Backend actually in use.
674    pub fn backend(&self) -> GpuBackend {
675        self.engine.backend()
676    }
677
678    /// Upload the optimizer state to device memory.
679    ///
680    /// Inherent alias for [`GpuOptimizer::move_to_gpu`] so callers do not
681    /// need a turbofish to pin the unused dimension parameter.
682    pub fn move_to_gpu(&mut self) -> Result<(), GpuOptimError> {
683        self.engine.move_to_gpu()
684    }
685
686    /// Download the optimizer state back to host memory.
687    ///
688    /// Inherent alias for [`GpuOptimizer::move_to_cpu`]; see
689    /// [`Self::move_to_gpu`].
690    pub fn move_to_cpu(&mut self) -> Result<(), GpuOptimError> {
691        self.engine.move_to_cpu()
692    }
693
694    /// Deprecated alias for [`Self::move_to_gpu`].
695    #[deprecated(since = "0.3.2", note = "renamed to `move_to_gpu`")]
696    pub fn to_gpu(&mut self) -> Result<(), GpuOptimError> {
697        self.move_to_gpu()
698    }
699
700    /// Deprecated alias for [`Self::move_to_cpu`].
701    #[deprecated(since = "0.3.2", note = "renamed to `move_to_cpu`")]
702    pub fn to_cpu(&mut self) -> Result<(), GpuOptimError> {
703        self.move_to_cpu()
704    }
705
706    /// Whether a real device (not the CPU fallback) is backing this optimizer.
707    pub fn is_gpu_available(&self) -> bool {
708        self.engine.backend() != GpuBackend::Cpu
709    }
710
711    /// Number of updates applied so far.
712    pub fn step_count(&self) -> u64 {
713        self.engine.step_count
714    }
715}
716
717impl<D: Dimension> GpuOptimizer<f32, D> for GpuSgd {
718    fn is_gpu_available(&self) -> bool {
719        self.engine.backend() != GpuBackend::Cpu
720    }
721
722    fn move_to_gpu(&mut self) -> Result<(), GpuOptimError> {
723        self.engine.move_to_gpu()
724    }
725
726    fn move_to_cpu(&mut self) -> Result<(), GpuOptimError> {
727        self.engine.move_to_cpu()
728    }
729
730    fn step_gpu(
731        &mut self,
732        params: &mut Array<f32, D>,
733        gradients: &Array<f32, D>,
734    ) -> Result<(), GpuOptimError> {
735        check_shapes(params, gradients)?;
736        let n = params.len();
737        self.engine.prepare(n)?;
738        let first = self.engine.step_count == 0;
739        self.engine.step_count = self.engine.step_count.saturating_add(1);
740
741        let hyper = [
742            self.params.learning_rate,
743            self.params.momentum,
744            self.params.dampening,
745            self.params.weight_decay,
746            if self.params.nesterov { 1.0 } else { 0.0 },
747            if first { 1.0 } else { 0.0 },
748            encode_u32(n)?,
749        ];
750
751        let host_params = to_host_vec(params);
752        let host_grads = to_host_vec(gradients);
753        let groups = workgroup_count(n)?;
754
755        let updated = {
756            let context = self.engine.cache.context();
757            let params_buf = context.create_buffer::<f32>(n);
758            params_buf.copy_from_host(&host_params)?;
759            let grads_buf = context.create_buffer::<f32>(n);
760            grads_buf.copy_from_host(&host_grads)?;
761            let hyper_buf = context.create_buffer::<f32>(hyper.len());
762            hyper_buf.copy_from_host(&hyper)?;
763            let buf = self.engine.state.device_slot(0)?.clone();
764
765            let kernel = self.engine.cache.kernel(OptimizerKernel::Sgd)?;
766            kernel.set_buffer("x", &params_buf);
767            kernel.set_buffer("y", &grads_buf);
768            kernel.set_buffer("a", &buf);
769            kernel.set_buffer("b", &hyper_buf);
770            kernel.dispatch([groups, 1, 1]);
771
772            let mut out = vec![0.0f32; n];
773            params_buf.copy_to_host(&mut out)?;
774            out
775        };
776
777        from_host_vec(params, &updated)
778    }
779}
780
781// ─── RMSprop ────────────────────────────────────────────────────────────────
782
783/// Hyper-parameters for [`GpuRmsprop`].
784#[derive(Debug, Clone, Copy, PartialEq)]
785pub struct RmspropParams {
786    /// Step size.
787    pub learning_rate: f32,
788    /// Smoothing constant for the squared-gradient average.
789    pub alpha: f32,
790    /// Denominator stabiliser.
791    pub epsilon: f32,
792    /// Coupled L2 weight decay.
793    pub weight_decay: f32,
794    /// Momentum factor (0 disables the momentum buffer).
795    pub momentum: f32,
796    /// Subtract the squared mean gradient from the variance estimate.
797    pub centered: bool,
798}
799
800impl Default for RmspropParams {
801    fn default() -> Self {
802        Self {
803            learning_rate: 1e-2,
804            alpha: 0.99,
805            epsilon: 1e-8,
806            weight_decay: 0.0,
807            momentum: 0.0,
808            centered: false,
809        }
810    }
811}
812
813impl RmspropParams {
814    fn validate(&self) -> Result<(), GpuOptimError> {
815        let invalid = |what: &str| GpuOptimError::InvalidState(format!("invalid RMSprop {what}"));
816        if !(self.learning_rate.is_finite() && self.learning_rate > 0.0) {
817            return Err(invalid("learning rate (must be finite and > 0)"));
818        }
819        if !(self.alpha.is_finite() && (0.0..1.0).contains(&self.alpha)) {
820            return Err(invalid("alpha (must be in [0, 1))"));
821        }
822        if !(self.epsilon.is_finite() && self.epsilon > 0.0) {
823            return Err(invalid("epsilon (must be finite and > 0)"));
824        }
825        if !(self.weight_decay.is_finite() && self.weight_decay >= 0.0) {
826            return Err(invalid("weight decay (must be finite and >= 0)"));
827        }
828        if !(self.momentum.is_finite() && self.momentum >= 0.0) {
829            return Err(invalid("momentum (must be finite and >= 0)"));
830        }
831        Ok(())
832    }
833}
834
835/// GPU RMSprop with optional centering and momentum.
836///
837/// The centered variant keeps a dedicated running mean-gradient buffer
838/// (`g_avg`), which is what makes `E[g^2] - E[g]^2` a real variance estimate.
839pub struct GpuRmsprop {
840    engine: GpuStepEngine,
841    params: RmspropParams,
842}
843
844impl GpuRmsprop {
845    /// Create the optimizer with the default WebGPU backend.
846    pub fn new(params: RmspropParams) -> Result<Self, GpuOptimError> {
847        Self::with_config(params, GpuOptimizerConfig::default())
848    }
849
850    /// Create the optimizer on an explicit backend.
851    pub fn with_config(
852        params: RmspropParams,
853        config: GpuOptimizerConfig,
854    ) -> Result<Self, GpuOptimError> {
855        params.validate()?;
856        // Slots: 0 = sq_avg, 1 = g_avg (centered), 2 = momentum buffer.
857        Ok(Self {
858            engine: GpuStepEngine::new(config, 3)?,
859            params,
860        })
861    }
862
863    /// Backend actually in use.
864    pub fn backend(&self) -> GpuBackend {
865        self.engine.backend()
866    }
867
868    /// Upload the optimizer state to device memory.
869    ///
870    /// Inherent alias for [`GpuOptimizer::move_to_gpu`] so callers do not
871    /// need a turbofish to pin the unused dimension parameter.
872    pub fn move_to_gpu(&mut self) -> Result<(), GpuOptimError> {
873        self.engine.move_to_gpu()
874    }
875
876    /// Download the optimizer state back to host memory.
877    ///
878    /// Inherent alias for [`GpuOptimizer::move_to_cpu`]; see
879    /// [`Self::move_to_gpu`].
880    pub fn move_to_cpu(&mut self) -> Result<(), GpuOptimError> {
881        self.engine.move_to_cpu()
882    }
883
884    /// Deprecated alias for [`Self::move_to_gpu`].
885    #[deprecated(since = "0.3.2", note = "renamed to `move_to_gpu`")]
886    pub fn to_gpu(&mut self) -> Result<(), GpuOptimError> {
887        self.move_to_gpu()
888    }
889
890    /// Deprecated alias for [`Self::move_to_cpu`].
891    #[deprecated(since = "0.3.2", note = "renamed to `move_to_cpu`")]
892    pub fn to_cpu(&mut self) -> Result<(), GpuOptimError> {
893        self.move_to_cpu()
894    }
895
896    /// Whether a real device (not the CPU fallback) is backing this optimizer.
897    pub fn is_gpu_available(&self) -> bool {
898        self.engine.backend() != GpuBackend::Cpu
899    }
900
901    /// Number of updates applied so far.
902    pub fn step_count(&self) -> u64 {
903        self.engine.step_count
904    }
905}
906
907impl<D: Dimension> GpuOptimizer<f32, D> for GpuRmsprop {
908    fn is_gpu_available(&self) -> bool {
909        self.engine.backend() != GpuBackend::Cpu
910    }
911
912    fn move_to_gpu(&mut self) -> Result<(), GpuOptimError> {
913        self.engine.move_to_gpu()
914    }
915
916    fn move_to_cpu(&mut self) -> Result<(), GpuOptimError> {
917        self.engine.move_to_cpu()
918    }
919
920    fn step_gpu(
921        &mut self,
922        params: &mut Array<f32, D>,
923        gradients: &Array<f32, D>,
924    ) -> Result<(), GpuOptimError> {
925        check_shapes(params, gradients)?;
926        let n = params.len();
927        self.engine.prepare(n)?;
928        self.engine.step_count = self.engine.step_count.saturating_add(1);
929
930        let hyper = [
931            self.params.learning_rate,
932            self.params.alpha,
933            self.params.epsilon,
934            self.params.weight_decay,
935            self.params.momentum,
936            if self.params.centered { 1.0 } else { 0.0 },
937            encode_u32(n)?,
938        ];
939
940        let host_params = to_host_vec(params);
941        let host_grads = to_host_vec(gradients);
942        let groups = workgroup_count(n)?;
943
944        let updated = {
945            let context = self.engine.cache.context();
946            let params_buf = context.create_buffer::<f32>(n);
947            params_buf.copy_from_host(&host_params)?;
948            let grads_buf = context.create_buffer::<f32>(n);
949            grads_buf.copy_from_host(&host_grads)?;
950            let hyper_buf = context.create_buffer::<f32>(hyper.len());
951            hyper_buf.copy_from_host(&hyper)?;
952            let sq_avg = self.engine.state.device_slot(0)?.clone();
953            let g_avg = self.engine.state.device_slot(1)?.clone();
954            let buf = self.engine.state.device_slot(2)?.clone();
955
956            let kernel = self.engine.cache.kernel(OptimizerKernel::Rmsprop)?;
957            kernel.set_buffer("x", &params_buf);
958            kernel.set_buffer("y", &grads_buf);
959            kernel.set_buffer("a", &sq_avg);
960            kernel.set_buffer("b", &g_avg);
961            kernel.set_buffer("result", &buf);
962            kernel.set_buffer("output", &hyper_buf);
963            kernel.dispatch([groups, 1, 1]);
964
965            let mut out = vec![0.0f32; n];
966            params_buf.copy_to_host(&mut out)?;
967            out
968        };
969
970        from_host_vec(params, &updated)
971    }
972}
973
974// ─── Adagrad ────────────────────────────────────────────────────────────────
975
976/// Hyper-parameters for [`GpuAdagrad`].
977#[derive(Debug, Clone, Copy, PartialEq)]
978pub struct AdagradParams {
979    /// Base step size.
980    pub learning_rate: f32,
981    /// Learning-rate decay applied per step.
982    pub lr_decay: f32,
983    /// Denominator stabiliser.
984    pub epsilon: f32,
985    /// Coupled L2 weight decay.
986    pub weight_decay: f32,
987}
988
989impl Default for AdagradParams {
990    fn default() -> Self {
991        Self {
992            learning_rate: 1e-2,
993            lr_decay: 0.0,
994            epsilon: 1e-10,
995            weight_decay: 0.0,
996        }
997    }
998}
999
1000impl AdagradParams {
1001    fn validate(&self) -> Result<(), GpuOptimError> {
1002        let invalid = |what: &str| GpuOptimError::InvalidState(format!("invalid Adagrad {what}"));
1003        if !(self.learning_rate.is_finite() && self.learning_rate > 0.0) {
1004            return Err(invalid("learning rate (must be finite and > 0)"));
1005        }
1006        if !(self.lr_decay.is_finite() && self.lr_decay >= 0.0) {
1007            return Err(invalid("lr_decay (must be finite and >= 0)"));
1008        }
1009        if !(self.epsilon.is_finite() && self.epsilon > 0.0) {
1010            return Err(invalid("epsilon (must be finite and > 0)"));
1011        }
1012        if !(self.weight_decay.is_finite() && self.weight_decay >= 0.0) {
1013            return Err(invalid("weight decay (must be finite and >= 0)"));
1014        }
1015        Ok(())
1016    }
1017
1018    /// Effective step size for a 1-based timestep.
1019    ///
1020    /// The first update (`step == 1`) uses exactly `learning_rate`; the decay
1021    /// denominator counts *completed* steps, so it is `step - 1`, not `step`.
1022    fn effective_lr(&self, step: u64) -> f32 {
1023        let completed = step.saturating_sub(1) as f32;
1024        self.learning_rate / (1.0 + completed * self.lr_decay)
1025    }
1026}
1027
1028/// GPU Adagrad with learning-rate decay.
1029pub struct GpuAdagrad {
1030    engine: GpuStepEngine,
1031    params: AdagradParams,
1032}
1033
1034impl GpuAdagrad {
1035    /// Create the optimizer with the default WebGPU backend.
1036    pub fn new(params: AdagradParams) -> Result<Self, GpuOptimError> {
1037        Self::with_config(params, GpuOptimizerConfig::default())
1038    }
1039
1040    /// Create the optimizer on an explicit backend.
1041    pub fn with_config(
1042        params: AdagradParams,
1043        config: GpuOptimizerConfig,
1044    ) -> Result<Self, GpuOptimError> {
1045        params.validate()?;
1046        Ok(Self {
1047            engine: GpuStepEngine::new(config, 1)?,
1048            params,
1049        })
1050    }
1051
1052    /// Backend actually in use.
1053    pub fn backend(&self) -> GpuBackend {
1054        self.engine.backend()
1055    }
1056
1057    /// Upload the optimizer state to device memory.
1058    ///
1059    /// Inherent alias for [`GpuOptimizer::move_to_gpu`] so callers do not
1060    /// need a turbofish to pin the unused dimension parameter.
1061    pub fn move_to_gpu(&mut self) -> Result<(), GpuOptimError> {
1062        self.engine.move_to_gpu()
1063    }
1064
1065    /// Download the optimizer state back to host memory.
1066    ///
1067    /// Inherent alias for [`GpuOptimizer::move_to_cpu`]; see
1068    /// [`Self::move_to_gpu`].
1069    pub fn move_to_cpu(&mut self) -> Result<(), GpuOptimError> {
1070        self.engine.move_to_cpu()
1071    }
1072
1073    /// Deprecated alias for [`Self::move_to_gpu`].
1074    #[deprecated(since = "0.3.2", note = "renamed to `move_to_gpu`")]
1075    pub fn to_gpu(&mut self) -> Result<(), GpuOptimError> {
1076        self.move_to_gpu()
1077    }
1078
1079    /// Deprecated alias for [`Self::move_to_cpu`].
1080    #[deprecated(since = "0.3.2", note = "renamed to `move_to_cpu`")]
1081    pub fn to_cpu(&mut self) -> Result<(), GpuOptimError> {
1082        self.move_to_cpu()
1083    }
1084
1085    /// Whether a real device (not the CPU fallback) is backing this optimizer.
1086    pub fn is_gpu_available(&self) -> bool {
1087        self.engine.backend() != GpuBackend::Cpu
1088    }
1089
1090    /// Number of updates applied so far.
1091    pub fn step_count(&self) -> u64 {
1092        self.engine.step_count
1093    }
1094
1095    /// Effective learning rate that the next step will use.
1096    pub fn next_effective_lr(&self) -> f32 {
1097        self.params.effective_lr(self.engine.step_count + 1)
1098    }
1099}
1100
1101impl<D: Dimension> GpuOptimizer<f32, D> for GpuAdagrad {
1102    fn is_gpu_available(&self) -> bool {
1103        self.engine.backend() != GpuBackend::Cpu
1104    }
1105
1106    fn move_to_gpu(&mut self) -> Result<(), GpuOptimError> {
1107        self.engine.move_to_gpu()
1108    }
1109
1110    fn move_to_cpu(&mut self) -> Result<(), GpuOptimError> {
1111        self.engine.move_to_cpu()
1112    }
1113
1114    fn step_gpu(
1115        &mut self,
1116        params: &mut Array<f32, D>,
1117        gradients: &Array<f32, D>,
1118    ) -> Result<(), GpuOptimError> {
1119        check_shapes(params, gradients)?;
1120        let n = params.len();
1121        self.engine.prepare(n)?;
1122        self.engine.step_count = self.engine.step_count.saturating_add(1);
1123
1124        let hyper = [
1125            self.params.effective_lr(self.engine.step_count),
1126            self.params.epsilon,
1127            self.params.weight_decay,
1128            encode_u32(n)?,
1129        ];
1130
1131        let host_params = to_host_vec(params);
1132        let host_grads = to_host_vec(gradients);
1133        let groups = workgroup_count(n)?;
1134
1135        let updated = {
1136            let context = self.engine.cache.context();
1137            let params_buf = context.create_buffer::<f32>(n);
1138            params_buf.copy_from_host(&host_params)?;
1139            let grads_buf = context.create_buffer::<f32>(n);
1140            grads_buf.copy_from_host(&host_grads)?;
1141            let hyper_buf = context.create_buffer::<f32>(hyper.len());
1142            hyper_buf.copy_from_host(&hyper)?;
1143            let sum = self.engine.state.device_slot(0)?.clone();
1144
1145            let kernel = self.engine.cache.kernel(OptimizerKernel::Adagrad)?;
1146            kernel.set_buffer("x", &params_buf);
1147            kernel.set_buffer("y", &grads_buf);
1148            kernel.set_buffer("a", &sum);
1149            kernel.set_buffer("b", &hyper_buf);
1150            kernel.dispatch([groups, 1, 1]);
1151
1152            let mut out = vec![0.0f32; n];
1153            params_buf.copy_to_host(&mut out)?;
1154            out
1155        };
1156
1157        from_host_vec(params, &updated)
1158    }
1159}
1160
1161// ─── LAMB ───────────────────────────────────────────────────────────────────
1162
1163/// GPU LAMB with a real layer-wise trust ratio.
1164///
1165/// One step is two dispatches of the same pipeline: the first advances the
1166/// moments, materialises the Adam-style update direction and reduces
1167/// `||params||^2` / `||update||^2` per workgroup; the host finishes the
1168/// reduction and computes `trust = ||params|| / ||update||`; the second applies
1169/// `p -= lr * trust * update`. When either norm is zero the ratio degenerates
1170/// to `1`, matching the reference implementation.
1171pub struct GpuLamb {
1172    engine: GpuStepEngine,
1173    params: AdamParams,
1174}
1175
1176impl GpuLamb {
1177    /// Create the optimizer with the default WebGPU backend.
1178    pub fn new(params: AdamParams) -> Result<Self, GpuOptimError> {
1179        Self::with_config(params, GpuOptimizerConfig::default())
1180    }
1181
1182    /// Create the optimizer on an explicit backend.
1183    pub fn with_config(
1184        params: AdamParams,
1185        config: GpuOptimizerConfig,
1186    ) -> Result<Self, GpuOptimError> {
1187        params.validate()?;
1188        // Persistent slots: 0 = m, 1 = v. The update direction and the norm
1189        // partials live in a per-step scratch buffer instead, because their
1190        // length depends on the workgroup count, not just on `n`.
1191        Ok(Self {
1192            engine: GpuStepEngine::new(config, 2)?,
1193            params,
1194        })
1195    }
1196
1197    /// Backend actually in use.
1198    pub fn backend(&self) -> GpuBackend {
1199        self.engine.backend()
1200    }
1201
1202    /// Upload the optimizer state to device memory.
1203    ///
1204    /// Inherent alias for [`GpuOptimizer::move_to_gpu`] so callers do not
1205    /// need a turbofish to pin the unused dimension parameter.
1206    pub fn move_to_gpu(&mut self) -> Result<(), GpuOptimError> {
1207        self.engine.move_to_gpu()
1208    }
1209
1210    /// Download the optimizer state back to host memory.
1211    ///
1212    /// Inherent alias for [`GpuOptimizer::move_to_cpu`]; see
1213    /// [`Self::move_to_gpu`].
1214    pub fn move_to_cpu(&mut self) -> Result<(), GpuOptimError> {
1215        self.engine.move_to_cpu()
1216    }
1217
1218    /// Deprecated alias for [`Self::move_to_gpu`].
1219    #[deprecated(since = "0.3.2", note = "renamed to `move_to_gpu`")]
1220    pub fn to_gpu(&mut self) -> Result<(), GpuOptimError> {
1221        self.move_to_gpu()
1222    }
1223
1224    /// Deprecated alias for [`Self::move_to_cpu`].
1225    #[deprecated(since = "0.3.2", note = "renamed to `move_to_cpu`")]
1226    pub fn to_cpu(&mut self) -> Result<(), GpuOptimError> {
1227        self.move_to_cpu()
1228    }
1229
1230    /// Whether a real device (not the CPU fallback) is backing this optimizer.
1231    pub fn is_gpu_available(&self) -> bool {
1232        self.engine.backend() != GpuBackend::Cpu
1233    }
1234
1235    /// Number of updates applied so far.
1236    pub fn step_count(&self) -> u64 {
1237        self.engine.step_count
1238    }
1239
1240    /// Trust ratio implied by a finished norm reduction.
1241    fn trust_ratio(param_norm: f32, update_norm: f32) -> f32 {
1242        if param_norm > 0.0 && update_norm > 0.0 {
1243            param_norm / update_norm
1244        } else {
1245            1.0
1246        }
1247    }
1248}
1249
1250impl<D: Dimension> GpuOptimizer<f32, D> for GpuLamb {
1251    fn is_gpu_available(&self) -> bool {
1252        self.engine.backend() != GpuBackend::Cpu
1253    }
1254
1255    fn move_to_gpu(&mut self) -> Result<(), GpuOptimError> {
1256        self.engine.move_to_gpu()
1257    }
1258
1259    fn move_to_cpu(&mut self) -> Result<(), GpuOptimError> {
1260        self.engine.move_to_cpu()
1261    }
1262
1263    fn step_gpu(
1264        &mut self,
1265        params: &mut Array<f32, D>,
1266        gradients: &Array<f32, D>,
1267    ) -> Result<(), GpuOptimError> {
1268        check_shapes(params, gradients)?;
1269        let n = params.len();
1270        self.engine.prepare(n)?;
1271        self.engine.step_count = self.engine.step_count.saturating_add(1);
1272
1273        let (bc1, bc2) = self.params.bias_corrections(self.engine.step_count);
1274        let groups = workgroup_count(n)?;
1275        let partial_len = (groups as usize).saturating_mul(2);
1276
1277        let host_params = to_host_vec(params);
1278        let host_grads = to_host_vec(gradients);
1279
1280        let mut hyper = [
1281            self.params.learning_rate,
1282            self.params.beta1,
1283            self.params.beta2,
1284            self.params.epsilon,
1285            self.params.weight_decay,
1286            bc1,
1287            bc2,
1288            encode_u32(n)?,
1289            encode_u32(0)?,
1290            1.0,
1291        ];
1292
1293        let updated = {
1294            let context = self.engine.cache.context();
1295            let params_buf = context.create_buffer::<f32>(n);
1296            params_buf.copy_from_host(&host_params)?;
1297            let grads_buf = context.create_buffer::<f32>(n);
1298            grads_buf.copy_from_host(&host_grads)?;
1299
1300            // Scratch layout: `update[0..n]` followed by `partials[n..]`, two
1301            // f32 per workgroup (sum of p^2, sum of update^2). Keeping them in
1302            // one buffer holds the binding count at six, which is the limit of
1303            // the deterministic Metal argument-table mapping.
1304            let scratch_len = n.saturating_add(partial_len);
1305            let scratch_buf = context.create_buffer::<f32>(scratch_len);
1306            scratch_buf.copy_from_host(&vec![0.0f32; scratch_len])?;
1307            let hyper_buf = context.create_buffer::<f32>(hyper.len());
1308            hyper_buf.copy_from_host(&hyper)?;
1309
1310            let m_buf = self.engine.state.device_slot(0)?.clone();
1311            let v_buf = self.engine.state.device_slot(1)?.clone();
1312
1313            // Phase 0: moments + update direction + partial norms.
1314            {
1315                let kernel = self.engine.cache.kernel(OptimizerKernel::Lamb)?;
1316                kernel.set_buffer("x", &params_buf);
1317                kernel.set_buffer("y", &grads_buf);
1318                kernel.set_buffer("a", &m_buf);
1319                kernel.set_buffer("b", &v_buf);
1320                kernel.set_buffer("result", &scratch_buf);
1321                kernel.set_buffer("output", &hyper_buf);
1322                kernel.dispatch([groups, 1, 1]);
1323            }
1324
1325            let mut scratch = vec![0.0f32; scratch_len];
1326            scratch_buf.copy_to_host(&mut scratch)?;
1327            let mut param_sq = 0.0f64;
1328            let mut update_sq = 0.0f64;
1329            for pair in scratch[n..].chunks_exact(2) {
1330                param_sq += f64::from(pair[0]);
1331                update_sq += f64::from(pair[1]);
1332            }
1333            let trust = Self::trust_ratio(
1334                param_sq.max(0.0).sqrt() as f32,
1335                update_sq.max(0.0).sqrt() as f32,
1336            );
1337
1338            // Phase 1: apply the trusted update.
1339            hyper[8] = encode_u32(1)?;
1340            hyper[9] = trust;
1341            hyper_buf.copy_from_host(&hyper)?;
1342            {
1343                let kernel = self.engine.cache.kernel(OptimizerKernel::Lamb)?;
1344                kernel.set_buffer("x", &params_buf);
1345                kernel.set_buffer("y", &grads_buf);
1346                kernel.set_buffer("a", &m_buf);
1347                kernel.set_buffer("b", &v_buf);
1348                kernel.set_buffer("result", &scratch_buf);
1349                kernel.set_buffer("output", &hyper_buf);
1350                kernel.dispatch([groups, 1, 1]);
1351            }
1352
1353            let mut out = vec![0.0f32; n];
1354            params_buf.copy_to_host(&mut out)?;
1355            out
1356        };
1357
1358        from_host_vec(params, &updated)
1359    }
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364    use super::*;
1365
1366    #[test]
1367    fn adam_params_reject_invalid_values() {
1368        assert!(AdamParams::new(1e-3, 0.9, 0.999, 1e-8, 0.0).is_ok());
1369        assert!(AdamParams::new(0.0, 0.9, 0.999, 1e-8, 0.0).is_err());
1370        assert!(AdamParams::new(1e-3, 1.0, 0.999, 1e-8, 0.0).is_err());
1371        assert!(AdamParams::new(1e-3, 0.9, f32::NAN, 1e-8, 0.0).is_err());
1372        assert!(AdamParams::new(1e-3, 0.9, 0.999, 0.0, 0.0).is_err());
1373        assert!(AdamParams::new(1e-3, 0.9, 0.999, 1e-8, -1.0).is_err());
1374    }
1375
1376    #[test]
1377    fn adagrad_lr_decay_is_not_off_by_one() {
1378        let params = AdagradParams {
1379            learning_rate: 0.1,
1380            lr_decay: 0.5,
1381            ..AdagradParams::default()
1382        };
1383        // First step must use the base rate exactly.
1384        assert!((params.effective_lr(1) - 0.1).abs() < 1e-9);
1385        // Second step divides by (1 + 1 * 0.5).
1386        assert!((params.effective_lr(2) - 0.1 / 1.5).abs() < 1e-9);
1387        assert!((params.effective_lr(3) - 0.1 / 2.0).abs() < 1e-9);
1388    }
1389
1390    #[test]
1391    fn encode_u32_round_trips_large_counts() {
1392        let n = 20_000_000usize; // > 2^24, not exactly representable as f32
1393        let encoded = encode_u32(n).expect("encodable");
1394        assert_eq!(encoded.to_bits() as usize, n);
1395    }
1396
1397    #[test]
1398    fn workgroup_count_covers_the_tail() {
1399        assert_eq!(workgroup_count(1).expect("ok"), 1);
1400        assert_eq!(workgroup_count(256).expect("ok"), 1);
1401        assert_eq!(workgroup_count(257).expect("ok"), 2);
1402        assert_eq!(workgroup_count(0).expect("ok"), 0);
1403    }
1404
1405    #[test]
1406    fn lamb_trust_ratio_degenerates_to_one() {
1407        assert_eq!(GpuLamb::trust_ratio(0.0, 1.0), 1.0);
1408        assert_eq!(GpuLamb::trust_ratio(1.0, 0.0), 1.0);
1409        assert!((GpuLamb::trust_ratio(4.0, 2.0) - 2.0).abs() < 1e-6);
1410    }
1411
1412    #[test]
1413    fn state_buffers_start_zeroed_on_the_host() {
1414        let state = StateBuffers::new(2, 8);
1415        assert!(!state.is_resident());
1416        assert_eq!(state.host.len(), 2);
1417        assert!(state.host.iter().all(|s| s.iter().all(|&x| x == 0.0)));
1418    }
1419
1420    #[test]
1421    fn non_wgpu_backends_are_rejected_explicitly() {
1422        for backend in [
1423            GpuBackend::Cpu,
1424            GpuBackend::Cuda,
1425            GpuBackend::Rocm,
1426            GpuBackend::OpenCL,
1427        ] {
1428            let err = KernelCache::new(Some(backend))
1429                .err()
1430                .unwrap_or_else(|| panic!("backend {backend} must be rejected"));
1431            assert!(
1432                matches!(err, GpuOptimError::UnsupportedOperation(_)),
1433                "backend {backend} produced the wrong error: {err}"
1434            );
1435        }
1436    }
1437}