1use std::fmt;
12
13use scirs2_core::ndarray::{Array, Dimension, ScalarOperand};
14use scirs2_core::numeric::Float;
15
16use super::{HardwareOptimizationConfig, HardwarePlatform, MemoryStrategy, QuantizationSupport};
17use crate::error::{OptimError, Result};
18use crate::optimizers::{Adam, Lion, Optimizer, LAMB, SGD};
19use crate::schedulers::{ConstantScheduler, LearningRateScheduler};
20use crate::utils::scalar_or;
21
22pub const DEFAULT_BASE_LEARNING_RATE: f64 = 1e-3;
29
30const LARGE_BATCH_THRESHOLD: usize = 512;
37
38const LOW_POWER_BUDGET_WATTS: f64 = 5.0;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub enum HardwareOptimizerKind {
50 Sgd,
54 Lion,
60 Adam,
63 Lamb,
66}
67
68impl HardwareOptimizerKind {
69 pub fn state_buffers_per_parameter(self) -> usize {
74 match self {
75 Self::Sgd => 1,
76 Self::Lion => 1,
77 Self::Adam => 2,
78 Self::Lamb => 2,
79 }
80 }
81
82 pub fn name(self) -> &'static str {
84 match self {
85 Self::Sgd => "sgd",
86 Self::Lion => "lion",
87 Self::Adam => "adam",
88 Self::Lamb => "lamb",
89 }
90 }
91
92 pub fn recommend_for<A: Float>(
104 platform: &HardwarePlatform,
105 config: &HardwareOptimizationConfig<A>,
106 ) -> Self {
107 let offloading = matches!(config.memory_strategy, MemoryStrategy::CPUOffloading { .. });
108
109 match platform {
110 HardwarePlatform::Edge {
111 power_budget,
112 quantization_support,
113 ..
114 } => {
115 if offloading || *power_budget < LOW_POWER_BUDGET_WATTS {
116 Self::Sgd
117 } else if matches!(
118 quantization_support,
119 QuantizationSupport::Int4 | QuantizationSupport::Int8
120 ) {
121 Self::Lion
122 } else if config.batch_size >= LARGE_BATCH_THRESHOLD {
123 Self::Lamb
124 } else {
125 Self::Adam
126 }
127 }
128 _ => {
129 if offloading {
130 Self::Sgd
131 } else if config.batch_size >= LARGE_BATCH_THRESHOLD {
132 Self::Lamb
133 } else {
134 Self::Adam
135 }
136 }
137 }
138 }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq)]
148pub struct HardwareStepReport<A: Float> {
149 pub applied: bool,
151 pub learning_rate: A,
154 pub step_count: usize,
156 pub accumulated_micro_steps: usize,
158}
159
160pub struct OptimizationState<A: Float + 'static, D: Dimension + 'static> {
162 parameters: Array<A, D>,
164 optimizer: Box<dyn Optimizer<A, D> + Send + Sync>,
166 optimizer_kind: HardwareOptimizerKind,
168 lr_schedule: Box<dyn LearningRateScheduler<A> + Send + Sync>,
170 base_learning_rate: A,
175 step_count: usize,
177 accumulated_micro_steps: usize,
179 accumulation_steps: usize,
181 gradient_accumulator: Option<Array<A, D>>,
184}
185
186impl<A, D> fmt::Debug for OptimizationState<A, D>
187where
188 A: Float + fmt::Debug + 'static,
189 D: Dimension + 'static,
190{
191 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194 f.debug_struct("OptimizationState")
195 .field("parameter_count", &self.parameters.len())
196 .field("optimizer_kind", &self.optimizer_kind)
197 .field("base_learning_rate", &self.base_learning_rate)
198 .field("step_count", &self.step_count)
199 .field("accumulation_steps", &self.accumulation_steps)
200 .field("accumulated_micro_steps", &self.accumulated_micro_steps)
201 .finish()
202 }
203}
204
205impl<A, D> OptimizationState<A, D>
206where
207 A: Float + ScalarOperand + std::fmt::Debug + Send + Sync + 'static,
208 D: Dimension + 'static,
209{
210 pub fn new(
215 parameters: Array<A, D>,
216 kind: HardwareOptimizerKind,
217 base_learning_rate: A,
218 accumulation_steps: usize,
219 ) -> Self {
220 Self {
221 parameters,
222 optimizer: build_optimizer(kind, base_learning_rate),
223 optimizer_kind: kind,
224 lr_schedule: Box::new(ConstantScheduler::new(base_learning_rate)),
225 base_learning_rate,
226 step_count: 0,
227 accumulated_micro_steps: 0,
228 accumulation_steps: accumulation_steps.max(1),
229 gradient_accumulator: None,
230 }
231 }
232
233 pub fn step(&mut self, gradients: &Array<A, D>) -> Result<HardwareStepReport<A>> {
244 if gradients.raw_dim() != self.parameters.raw_dim() {
245 return Err(OptimError::DimensionMismatch(format!(
246 "hardware-aware step: parameters have shape {:?} but the gradient has shape {:?}",
247 self.parameters.raw_dim().slice(),
248 gradients.raw_dim().slice()
249 )));
250 }
251
252 let effective_gradient = if self.accumulation_steps == 1 {
253 gradients.clone()
254 } else {
255 let shape = self.parameters.raw_dim();
256 let accumulator = self
257 .gradient_accumulator
258 .get_or_insert_with(|| Array::zeros(shape));
259 for (slot, &g) in accumulator.iter_mut().zip(gradients.iter()) {
260 *slot = *slot + g;
261 }
262 self.accumulated_micro_steps += 1;
263
264 if self.accumulated_micro_steps < self.accumulation_steps {
265 return Ok(HardwareStepReport {
266 applied: false,
267 learning_rate: self.lr_schedule.get_learning_rate(),
268 step_count: self.step_count,
269 accumulated_micro_steps: self.accumulated_micro_steps,
270 });
271 }
272
273 let window = scalar_or(self.accumulated_micro_steps, A::one());
274 let averaged = accumulator.mapv(|g| g / window);
275 accumulator.fill(A::zero());
276 self.accumulated_micro_steps = 0;
277 averaged
278 };
279
280 let learning_rate = self.lr_schedule.get_learning_rate();
284 self.optimizer.set_learning_rate(learning_rate);
285 self.parameters = self.optimizer.step(&self.parameters, &effective_gradient)?;
286 self.step_count += 1;
287 self.lr_schedule.step();
288
289 Ok(HardwareStepReport {
290 applied: true,
291 learning_rate,
292 step_count: self.step_count,
293 accumulated_micro_steps: 0,
294 })
295 }
296
297 pub fn parameters(&self) -> &Array<A, D> {
299 &self.parameters
300 }
301
302 pub fn step_count(&self) -> usize {
304 self.step_count
305 }
306
307 pub fn optimizer_kind(&self) -> HardwareOptimizerKind {
309 self.optimizer_kind
310 }
311
312 pub fn learning_rate(&self) -> A {
314 self.lr_schedule.get_learning_rate()
315 }
316
317 pub fn base_learning_rate(&self) -> A {
320 self.base_learning_rate
321 }
322
323 pub fn accumulation_steps(&self) -> usize {
325 self.accumulation_steps
326 }
327
328 pub fn accumulated_micro_steps(&self) -> usize {
330 self.accumulated_micro_steps
331 }
332
333 pub fn set_accumulation_steps(&mut self, accumulation_steps: usize) {
339 self.accumulation_steps = accumulation_steps.max(1);
340 if let Some(accumulator) = self.gradient_accumulator.as_mut() {
341 accumulator.fill(A::zero());
342 }
343 self.accumulated_micro_steps = 0;
344 }
345
346 pub fn set_lr_scheduler(&mut self, schedule: Box<dyn LearningRateScheduler<A> + Send + Sync>) {
356 self.lr_schedule = schedule;
357 }
358
359 pub fn rebuild_optimizer(&mut self, kind: HardwareOptimizerKind) {
365 self.optimizer = build_optimizer(kind, self.lr_schedule.get_learning_rate());
366 self.optimizer_kind = kind;
367 }
368
369 pub fn set_optimizer(
376 &mut self,
377 kind: HardwareOptimizerKind,
378 optimizer: Box<dyn Optimizer<A, D> + Send + Sync>,
379 ) {
380 self.optimizer = optimizer;
381 self.optimizer_kind = kind;
382 }
383}
384
385fn build_optimizer<A, D>(
387 kind: HardwareOptimizerKind,
388 learning_rate: A,
389) -> Box<dyn Optimizer<A, D> + Send + Sync>
390where
391 A: Float + ScalarOperand + std::fmt::Debug + Send + Sync + 'static,
392 D: Dimension + 'static,
393{
394 match kind {
395 HardwareOptimizerKind::Sgd => Box::new(SGD::new_with_config(
400 learning_rate,
401 scalar_or(0.9, A::zero()),
402 A::zero(),
403 )),
404 HardwareOptimizerKind::Lion => Box::new(Lion::new(learning_rate)),
405 HardwareOptimizerKind::Adam => Box::new(Adam::new(learning_rate)),
406 HardwareOptimizerKind::Lamb => Box::new(LAMB::new(learning_rate)),
407 }
408}
409
410pub(super) fn accumulation_steps_for(strategy: &MemoryStrategy) -> usize {
416 match strategy {
417 MemoryStrategy::GradientAccumulation { accumulation_steps } => (*accumulation_steps).max(1),
418 MemoryStrategy::Mixed { strategies, .. } => strategies
419 .iter()
420 .map(accumulation_steps_for)
421 .max()
422 .unwrap_or(1),
423 _ => 1,
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use crate::schedulers::ExponentialDecay;
431 use scirs2_core::ndarray::{Array1, Ix1};
432
433 fn quadratic_gradient(parameters: &Array1<f64>) -> Array1<f64> {
435 parameters.mapv(|x| 2.0 * x)
436 }
437
438 fn quadratic_loss(parameters: &Array1<f64>) -> f64 {
440 parameters.iter().map(|&x| x * x).sum()
441 }
442
443 #[test]
446 fn steps_reduce_a_quadratic_loss() {
447 for kind in [
448 HardwareOptimizerKind::Sgd,
449 HardwareOptimizerKind::Lion,
450 HardwareOptimizerKind::Adam,
451 HardwareOptimizerKind::Lamb,
452 ] {
453 let start = Array1::from_vec(vec![1.0, -2.0, 3.0]);
454 let initial_loss = quadratic_loss(&start);
455 let mut state: OptimizationState<f64, Ix1> =
456 OptimizationState::new(start, kind, 0.05, 1);
457
458 for _ in 0..200 {
459 let gradient = quadratic_gradient(state.parameters());
460 let report = state.step(&gradient).expect("step must succeed");
461 assert!(report.applied, "{} did not apply an update", kind.name());
462 }
463
464 let final_loss = quadratic_loss(state.parameters());
465 assert_eq!(state.step_count(), 200, "{}", kind.name());
466 assert!(
467 final_loss < initial_loss * 0.5,
468 "{}: loss did not decrease ({initial_loss} -> {final_loss})",
469 kind.name()
470 );
471 }
472 }
473
474 #[test]
476 fn mismatched_gradient_shape_is_reported() {
477 let mut state: OptimizationState<f64, Ix1> = OptimizationState::new(
478 Array1::from_vec(vec![1.0, 2.0]),
479 HardwareOptimizerKind::Adam,
480 0.01,
481 1,
482 );
483 let error = state
484 .step(&Array1::from_vec(vec![1.0, 2.0, 3.0]))
485 .expect_err("a shape mismatch must be reported");
486 assert!(
487 matches!(error, OptimError::DimensionMismatch(_)),
488 "{error:?}"
489 );
490 }
491
492 #[test]
495 fn gradient_accumulation_updates_once_per_window() {
496 let mut state: OptimizationState<f64, Ix1> = OptimizationState::new(
497 Array1::from_vec(vec![0.0, 0.0]),
498 HardwareOptimizerKind::Sgd,
499 0.1,
500 3,
501 );
502 let gradient = Array1::from_vec(vec![1.0, 1.0]);
503
504 for micro in 1..=2 {
505 let report = state.step(&gradient).expect("accumulating step");
506 assert!(!report.applied, "micro-batch {micro} must not update");
507 assert_eq!(report.accumulated_micro_steps, micro);
508 assert_eq!(state.parameters()[0], 0.0);
509 }
510
511 let report = state.step(&gradient).expect("closing step");
512 assert!(report.applied, "the full window must apply an update");
513 assert_eq!(state.step_count(), 1);
514 assert!(state.parameters()[0] < 0.0);
515 }
516
517 #[test]
521 fn accumulation_applies_the_window_mean() {
522 let gradient = Array1::from_vec(vec![1.0, -0.5]);
523
524 let mut direct: OptimizationState<f64, Ix1> = OptimizationState::new(
525 Array1::from_vec(vec![0.0, 0.0]),
526 HardwareOptimizerKind::Sgd,
527 0.1,
528 1,
529 );
530 direct.step(&gradient).expect("direct step");
531
532 let mut accumulated: OptimizationState<f64, Ix1> = OptimizationState::new(
533 Array1::from_vec(vec![0.0, 0.0]),
534 HardwareOptimizerKind::Sgd,
535 0.1,
536 3,
537 );
538 for _ in 0..3 {
539 accumulated.step(&gradient).expect("accumulated step");
540 }
541
542 for (index, (&direct_value, &accumulated_value)) in direct
543 .parameters()
544 .iter()
545 .zip(accumulated.parameters().iter())
546 .enumerate()
547 {
548 assert!(
549 (direct_value - accumulated_value).abs() < 1e-12,
550 "coordinate {index}: {direct_value} != {accumulated_value}"
551 );
552 }
553 }
554
555 #[test]
557 fn the_schedule_drives_the_optimizer_learning_rate() {
558 let mut state: OptimizationState<f64, Ix1> = OptimizationState::new(
559 Array1::from_vec(vec![1.0]),
560 HardwareOptimizerKind::Sgd,
561 0.1,
562 1,
563 );
564 state.set_lr_scheduler(Box::new(ExponentialDecay::new(0.1, 0.5, 1)));
565 assert!((state.learning_rate() - 0.1).abs() < 1e-12);
566 assert!((state.base_learning_rate() - 0.1).abs() < 1e-12);
567
568 let first = state
569 .step(&Array1::from_vec(vec![1.0]))
570 .expect("first step");
571 assert!((first.learning_rate - 0.1).abs() < 1e-12);
572
573 let second = state
574 .step(&Array1::from_vec(vec![1.0]))
575 .expect("second step");
576 assert!(
577 second.learning_rate < first.learning_rate,
578 "the schedule did not decay: {} -> {}",
579 first.learning_rate,
580 second.learning_rate
581 );
582 assert!(
583 (state.base_learning_rate() - 0.1).abs() < 1e-12,
584 "the construction-time rate must stay put so the decay is measurable"
585 );
586 }
587
588 #[test]
591 fn each_family_reports_its_optimizer_state_footprint() {
592 assert_eq!(HardwareOptimizerKind::Sgd.state_buffers_per_parameter(), 1);
593 assert_eq!(HardwareOptimizerKind::Lion.state_buffers_per_parameter(), 1);
594 assert_eq!(HardwareOptimizerKind::Adam.state_buffers_per_parameter(), 2);
595 assert_eq!(HardwareOptimizerKind::Lamb.state_buffers_per_parameter(), 2);
596 assert!(
597 HardwareOptimizerKind::Lion.state_buffers_per_parameter()
598 < HardwareOptimizerKind::Adam.state_buffers_per_parameter(),
599 "Lion is recommended for edge devices precisely because it is cheaper"
600 );
601 }
602
603 #[test]
605 fn optimizer_recommendation_follows_the_platform() {
606 let edge = HardwarePlatform::Edge {
607 power_budget: 2.0,
608 memory_limit: 256 * 1024 * 1024,
609 quantization_support: QuantizationSupport::Int8,
610 };
611 let mut config: HardwareOptimizationConfig<f64> = HardwareOptimizationConfig {
612 batch_size: 16,
613 memory_strategy: MemoryStrategy::Standard,
614 parallelization: super::super::ParallelizationStrategy::SingleThread,
615 precision: super::super::PrecisionStrategy::FP32,
616 optimizer_params: std::collections::HashMap::new(),
617 communication: None,
618 };
619
620 assert_eq!(
622 HardwareOptimizerKind::recommend_for(&edge, &config),
623 HardwareOptimizerKind::Sgd
624 );
625
626 let roomy_edge = HardwarePlatform::Edge {
627 power_budget: 30.0,
628 memory_limit: 4 * 1024 * 1024 * 1024,
629 quantization_support: QuantizationSupport::Int8,
630 };
631 assert_eq!(
632 HardwareOptimizerKind::recommend_for(&roomy_edge, &config),
633 HardwareOptimizerKind::Lion
634 );
635
636 let gpu = HardwarePlatform::GPU {
637 memory: 16 * 1024 * 1024 * 1024,
638 compute_units: 80,
639 memory_bandwidth: 900.0,
640 architecture: super::super::GPUArchitecture::Ampere,
641 };
642 config.batch_size = 128;
643 assert_eq!(
644 HardwareOptimizerKind::recommend_for(&gpu, &config),
645 HardwareOptimizerKind::Adam
646 );
647
648 config.batch_size = 4096;
649 assert_eq!(
650 HardwareOptimizerKind::recommend_for(&gpu, &config),
651 HardwareOptimizerKind::Lamb
652 );
653
654 config.memory_strategy = MemoryStrategy::CPUOffloading { offload_ratio: 0.8 };
655 assert_eq!(
656 HardwareOptimizerKind::recommend_for(&gpu, &config),
657 HardwareOptimizerKind::Sgd
658 );
659 }
660
661 #[test]
663 fn accumulation_window_survives_a_mixed_memory_strategy() {
664 assert_eq!(accumulation_steps_for(&MemoryStrategy::Standard), 1);
665 assert_eq!(
666 accumulation_steps_for(&MemoryStrategy::GradientAccumulation {
667 accumulation_steps: 0
668 }),
669 1,
670 "a zero window would mean the parameters never move"
671 );
672 assert_eq!(
673 accumulation_steps_for(&MemoryStrategy::Mixed {
674 strategies: vec![
675 MemoryStrategy::Standard,
676 MemoryStrategy::GradientAccumulation {
677 accumulation_steps: 4
678 },
679 ],
680 strategy_weights: vec![0.5, 0.5],
681 }),
682 4
683 );
684 }
685}