1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub struct GpuOptimizerConfig {
34 pub backend: Option<GpuBackend>,
39}
40
41impl GpuOptimizerConfig {
42 pub fn with_backend(backend: GpuBackend) -> Self {
44 Self {
45 backend: Some(backend),
46 }
47 }
48}
49
50pub const SUPPORTED_BACKENDS: [GpuBackend; 2] = [GpuBackend::Wgpu, GpuBackend::Metal];
57
58fn 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
68fn 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
79struct 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 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
165struct StateBuffers {
171 slots: usize,
173 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 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 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 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
234struct 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 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 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
313fn 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
321fn 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
338fn 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#[derive(Debug, Clone, Copy, PartialEq)]
356pub struct AdamParams {
357 pub learning_rate: f32,
359 pub beta1: f32,
361 pub beta2: f32,
363 pub epsilon: f32,
365 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 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 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 pub fn new(params: AdamParams) -> Result<Self, GpuOptimError> {
442 Self::with_config(params, GpuOptimizerConfig::default())
443 }
444
445 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 pub fn backend(&self) -> GpuBackend {
459 self.engine.backend()
460 }
461
462 pub fn move_to_gpu(&mut self) -> Result<(), GpuOptimError> {
467 self.engine.move_to_gpu()
468 }
469
470 pub fn move_to_cpu(&mut self) -> Result<(), GpuOptimError> {
475 self.engine.move_to_cpu()
476 }
477
478 #[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(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 pub fn is_gpu_available(&self) -> bool {
492 self.engine.backend() != GpuBackend::Cpu
493 }
494
495 pub fn step_count(&self) -> u64 {
497 self.engine.step_count
498 }
499
500 pub fn params(&self) -> &AdamParams {
502 &self.params
503 }
504
505 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", ¶ms_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#[derive(Debug, Clone, Copy, PartialEq)]
600pub struct SgdParams {
601 pub learning_rate: f32,
603 pub momentum: f32,
605 pub dampening: f32,
607 pub weight_decay: f32,
609 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
649pub struct GpuSgd {
651 engine: GpuStepEngine,
652 params: SgdParams,
653}
654
655impl GpuSgd {
656 pub fn new(params: SgdParams) -> Result<Self, GpuOptimError> {
658 Self::with_config(params, GpuOptimizerConfig::default())
659 }
660
661 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 pub fn backend(&self) -> GpuBackend {
675 self.engine.backend()
676 }
677
678 pub fn move_to_gpu(&mut self) -> Result<(), GpuOptimError> {
683 self.engine.move_to_gpu()
684 }
685
686 pub fn move_to_cpu(&mut self) -> Result<(), GpuOptimError> {
691 self.engine.move_to_cpu()
692 }
693
694 #[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(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 pub fn is_gpu_available(&self) -> bool {
708 self.engine.backend() != GpuBackend::Cpu
709 }
710
711 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", ¶ms_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#[derive(Debug, Clone, Copy, PartialEq)]
785pub struct RmspropParams {
786 pub learning_rate: f32,
788 pub alpha: f32,
790 pub epsilon: f32,
792 pub weight_decay: f32,
794 pub momentum: f32,
796 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
835pub struct GpuRmsprop {
840 engine: GpuStepEngine,
841 params: RmspropParams,
842}
843
844impl GpuRmsprop {
845 pub fn new(params: RmspropParams) -> Result<Self, GpuOptimError> {
847 Self::with_config(params, GpuOptimizerConfig::default())
848 }
849
850 pub fn with_config(
852 params: RmspropParams,
853 config: GpuOptimizerConfig,
854 ) -> Result<Self, GpuOptimError> {
855 params.validate()?;
856 Ok(Self {
858 engine: GpuStepEngine::new(config, 3)?,
859 params,
860 })
861 }
862
863 pub fn backend(&self) -> GpuBackend {
865 self.engine.backend()
866 }
867
868 pub fn move_to_gpu(&mut self) -> Result<(), GpuOptimError> {
873 self.engine.move_to_gpu()
874 }
875
876 pub fn move_to_cpu(&mut self) -> Result<(), GpuOptimError> {
881 self.engine.move_to_cpu()
882 }
883
884 #[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(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 pub fn is_gpu_available(&self) -> bool {
898 self.engine.backend() != GpuBackend::Cpu
899 }
900
901 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", ¶ms_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#[derive(Debug, Clone, Copy, PartialEq)]
978pub struct AdagradParams {
979 pub learning_rate: f32,
981 pub lr_decay: f32,
983 pub epsilon: f32,
985 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 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
1028pub struct GpuAdagrad {
1030 engine: GpuStepEngine,
1031 params: AdagradParams,
1032}
1033
1034impl GpuAdagrad {
1035 pub fn new(params: AdagradParams) -> Result<Self, GpuOptimError> {
1037 Self::with_config(params, GpuOptimizerConfig::default())
1038 }
1039
1040 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 pub fn backend(&self) -> GpuBackend {
1054 self.engine.backend()
1055 }
1056
1057 pub fn move_to_gpu(&mut self) -> Result<(), GpuOptimError> {
1062 self.engine.move_to_gpu()
1063 }
1064
1065 pub fn move_to_cpu(&mut self) -> Result<(), GpuOptimError> {
1070 self.engine.move_to_cpu()
1071 }
1072
1073 #[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(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 pub fn is_gpu_available(&self) -> bool {
1087 self.engine.backend() != GpuBackend::Cpu
1088 }
1089
1090 pub fn step_count(&self) -> u64 {
1092 self.engine.step_count
1093 }
1094
1095 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", ¶ms_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
1161pub struct GpuLamb {
1172 engine: GpuStepEngine,
1173 params: AdamParams,
1174}
1175
1176impl GpuLamb {
1177 pub fn new(params: AdamParams) -> Result<Self, GpuOptimError> {
1179 Self::with_config(params, GpuOptimizerConfig::default())
1180 }
1181
1182 pub fn with_config(
1184 params: AdamParams,
1185 config: GpuOptimizerConfig,
1186 ) -> Result<Self, GpuOptimError> {
1187 params.validate()?;
1188 Ok(Self {
1192 engine: GpuStepEngine::new(config, 2)?,
1193 params,
1194 })
1195 }
1196
1197 pub fn backend(&self) -> GpuBackend {
1199 self.engine.backend()
1200 }
1201
1202 pub fn move_to_gpu(&mut self) -> Result<(), GpuOptimError> {
1207 self.engine.move_to_gpu()
1208 }
1209
1210 pub fn move_to_cpu(&mut self) -> Result<(), GpuOptimError> {
1215 self.engine.move_to_cpu()
1216 }
1217
1218 #[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(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 pub fn is_gpu_available(&self) -> bool {
1232 self.engine.backend() != GpuBackend::Cpu
1233 }
1234
1235 pub fn step_count(&self) -> u64 {
1237 self.engine.step_count
1238 }
1239
1240 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 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 {
1315 let kernel = self.engine.cache.kernel(OptimizerKernel::Lamb)?;
1316 kernel.set_buffer("x", ¶ms_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 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", ¶ms_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 assert!((params.effective_lr(1) - 0.1).abs() < 1e-9);
1385 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; 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}