Skip to main content

optirs_core/hardware_aware/
optimization_state.rs

1// Real optimizer step path for the hardware-aware optimizer.
2//
3// Before this module existed, `OptimizationState` tracked a parameter array and
4// nothing else: the hardware analysis in [`super`] recommended batch sizes,
5// precision and memory strategies but never actually ran an optimization step,
6// so a `HardwareAwareOptimizer` could not optimize anything. The state now owns
7// a real optimizer behind the crate's [`Optimizer`] trait, an explicit learning
8// rate schedule, a step counter and the gradient accumulator that
9// [`MemoryStrategy::GradientAccumulation`] configures.
10
11use 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
22/// Learning rate used when the caller does not supply one.
23///
24/// `1e-3` is the value every adaptive optimizer in this crate uses as its own
25/// default, so a hardware-aware optimizer built with
26/// [`super::HardwareAwareOptimizer::new`] behaves like a directly constructed
27/// `Adam`/`Lion`/`LAMB` until the caller says otherwise.
28pub const DEFAULT_BASE_LEARNING_RATE: f64 = 1e-3;
29
30/// Batch size at or above which a large-batch optimizer is recommended.
31///
32/// LAMB was introduced precisely because Adam's update stops scaling past a few
33/// hundred samples per step (You et al., "Large Batch Optimization for Deep
34/// Learning: Training BERT in 76 minutes", arXiv:1904.00962); the TPU and
35/// distributed configurations in [`super`] routinely produce batches this large.
36const LARGE_BATCH_THRESHOLD: usize = 512;
37
38/// Power budget (watts) below which optimizer state, not compute, is the binding
39/// constraint on an edge device.
40const LOW_POWER_BUDGET_WATTS: f64 = 5.0;
41
42/// Optimizer families the hardware analysis can recommend.
43///
44/// The distinguishing property is how much *per-parameter optimizer state* each
45/// family needs, because on the memory-constrained platforms this module models
46/// that state — not the parameters themselves — is what decides whether a model
47/// fits.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub enum HardwareOptimizerKind {
50    /// Momentum SGD: one auxiliary buffer per parameter, and none at all when
51    /// momentum is zero. Recommended when optimizer state has to be paid for
52    /// over a slow link (CPU offloading) or under a very small power budget.
53    Sgd,
54    /// Lion: a single momentum buffer and a sign-based update
55    /// (Chen et al., "Symbolic Discovery of Optimization Algorithms",
56    /// arXiv:2302.06675). Half of Adam's optimizer state, and the sign update
57    /// is insensitive to gradient scale, which suits the quantized/low-precision
58    /// arithmetic edge devices use.
59    Lion,
60    /// Adam: first and second moments, two buffers per parameter. The default
61    /// when optimizer-state memory is not the binding constraint.
62    Adam,
63    /// LAMB: Adam's moments plus a layer-wise trust ratio, for the very large
64    /// batches TPU and distributed configurations produce.
65    Lamb,
66}
67
68impl HardwareOptimizerKind {
69    /// Number of per-parameter auxiliary buffers this family keeps.
70    ///
71    /// Multiply by the parameter count and the element size to get the
72    /// optimizer-state footprint that has to fit alongside the model.
73    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    /// Stable short name, for diagnostics and reports.
83    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    /// Recommend an optimizer family for a platform and the configuration the
93    /// hardware analysis produced for it.
94    ///
95    /// The rules are ordered from the hardest constraint to the softest:
96    ///
97    /// 1. Offloading optimizer state to CPU memory, or a sub-`LOW_POWER_BUDGET_WATTS`
98    ///    power budget, makes every extra per-parameter buffer expensive: use SGD.
99    /// 2. Any other edge device: use Lion, which halves Adam's state and whose
100    ///    sign update tolerates quantized arithmetic.
101    /// 3. Batches of `LARGE_BATCH_THRESHOLD` or more (TPU, distributed): use LAMB.
102    /// 4. Otherwise: Adam.
103    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/// Outcome of a single call to [`OptimizationState::step`].
142///
143/// `applied` is `false` while gradient accumulation is still filling its window:
144/// the gradient was recorded but the parameters did not move, which is exactly
145/// what [`MemoryStrategy::GradientAccumulation`] asks for and what a caller
146/// driving a training loop needs to know.
147#[derive(Debug, Clone, Copy, PartialEq)]
148pub struct HardwareStepReport<A: Float> {
149    /// Whether an optimizer update was applied to the parameters.
150    pub applied: bool,
151    /// Learning rate the schedule supplied for this update. While accumulating
152    /// this is the rate the *next* update will use.
153    pub learning_rate: A,
154    /// Number of optimizer updates applied so far.
155    pub step_count: usize,
156    /// Micro-batches currently held in the gradient accumulator.
157    pub accumulated_micro_steps: usize,
158}
159
160/// Current optimization state: parameters plus everything needed to move them.
161pub struct OptimizationState<A: Float + 'static, D: Dimension + 'static> {
162    /// Current parameters.
163    parameters: Array<A, D>,
164    /// The optimizer that actually performs the update.
165    optimizer: Box<dyn Optimizer<A, D> + Send + Sync>,
166    /// Which family `optimizer` belongs to.
167    optimizer_kind: HardwareOptimizerKind,
168    /// Learning rate schedule driving `optimizer` between updates.
169    lr_schedule: Box<dyn LearningRateScheduler<A> + Send + Sync>,
170    /// The rate this state was constructed with. Reported by
171    /// [`OptimizationState::base_learning_rate`] so a caller can tell how far a
172    /// schedule has moved; the rate actually in effect always comes from
173    /// `lr_schedule`, never from here.
174    base_learning_rate: A,
175    /// Number of optimizer updates applied.
176    step_count: usize,
177    /// Micro-batches folded into `gradient_accumulator` since the last update.
178    accumulated_micro_steps: usize,
179    /// Micro-batches per optimizer update (1 = update on every gradient).
180    accumulation_steps: usize,
181    /// Running sum of the gradients of the current accumulation window.
182    /// Allocated lazily so a state that never accumulates costs nothing.
183    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    // Hand-written because neither the boxed optimizer nor the boxed schedule is
192    // `Debug`; the fields that matter for diagnostics are printed by value.
193    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    /// Build a state around `parameters`, using the optimizer family `kind`.
211    ///
212    /// `accumulation_steps` is clamped to at least 1: zero micro-batches per
213    /// update would mean the parameters never move.
214    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    /// Apply one gradient.
234    ///
235    /// With `accumulation_steps == 1` this performs an optimizer update
236    /// immediately. Otherwise the gradient is summed into the accumulator and
237    /// the update happens once the window is full, using the *mean* of the
238    /// window so the effective learning rate does not scale with the number of
239    /// micro-batches.
240    ///
241    /// A gradient whose shape differs from the parameters is reported rather
242    /// than silently zipped against a truncated view.
243    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        // Apply the schedule's current rate, then advance it — the same order a
281        // training loop uses, so the first update runs at the configured base
282        // rate rather than at an already-decayed one.
283        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    /// Current parameters.
298    pub fn parameters(&self) -> &Array<A, D> {
299        &self.parameters
300    }
301
302    /// Number of optimizer updates applied so far.
303    pub fn step_count(&self) -> usize {
304        self.step_count
305    }
306
307    /// Optimizer family currently in use.
308    pub fn optimizer_kind(&self) -> HardwareOptimizerKind {
309        self.optimizer_kind
310    }
311
312    /// Learning rate the next update will use.
313    pub fn learning_rate(&self) -> A {
314        self.lr_schedule.get_learning_rate()
315    }
316
317    /// Rate this state was constructed with, for comparison against the live
318    /// [`OptimizationState::learning_rate`].
319    pub fn base_learning_rate(&self) -> A {
320        self.base_learning_rate
321    }
322
323    /// Micro-batches per optimizer update.
324    pub fn accumulation_steps(&self) -> usize {
325        self.accumulation_steps
326    }
327
328    /// Micro-batches currently held in the accumulator.
329    pub fn accumulated_micro_steps(&self) -> usize {
330        self.accumulated_micro_steps
331    }
332
333    /// Change the accumulation window.
334    ///
335    /// Any partially accumulated window is discarded: mixing gradients averaged
336    /// over different window sizes would silently change the effective learning
337    /// rate of the next update.
338    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    /// Install a learning rate schedule.
347    ///
348    /// The schedule owns the rate from this point on: every update reads
349    /// [`LearningRateScheduler::get_learning_rate`] and pushes it into the
350    /// optimizer, so a rate set directly on the optimizer would be overwritten.
351    ///
352    /// `base_learning_rate` is deliberately left alone: it records what this
353    /// state was constructed with, which is the only thing a later comparison
354    /// against the live rate can be meaningful against.
355    pub fn set_lr_scheduler(&mut self, schedule: Box<dyn LearningRateScheduler<A> + Send + Sync>) {
356        self.lr_schedule = schedule;
357    }
358
359    /// Replace the optimizer with a freshly built one of family `kind`.
360    ///
361    /// This discards the accumulated moment state, which is why
362    /// [`super::HardwareAwareOptimizer::optimize_for_hardware`] only does it
363    /// before the first update.
364    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    /// Install a caller-supplied optimizer, for the cases the built-in
370    /// recommendation does not cover.
371    ///
372    /// `kind` is what the state will report from
373    /// [`OptimizationState::optimizer_kind`]; pass the family `optimizer`
374    /// actually belongs to so the reported state stays truthful.
375    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
385/// Instantiate the optimizer for a family at a given learning rate.
386fn 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        // A momentum term costs one buffer per parameter and is what makes SGD
396        // competitive on the ill-conditioned problems these platforms run; the
397        // `Sgd` recommendation is about avoiding Adam's *second* moment, not
398        // about running raw gradient descent.
399        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
410/// Micro-batches per optimizer update implied by a memory strategy.
411///
412/// Only [`MemoryStrategy::GradientAccumulation`] asks for accumulation; a
413/// `Mixed` strategy takes the largest window any of its components requests, so
414/// combining accumulation with another strategy does not silently drop it.
415pub(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    /// Gradient of `f(x) = sum(x_i^2)`.
434    fn quadratic_gradient(parameters: &Array1<f64>) -> Array1<f64> {
435        parameters.mapv(|x| 2.0 * x)
436    }
437
438    /// `f(x) = sum(x_i^2)`.
439    fn quadratic_loss(parameters: &Array1<f64>) -> f64 {
440        parameters.iter().map(|&x| x * x).sum()
441    }
442
443    /// The state must actually run optimizer steps: before this module it only
444    /// held a parameter array, so a loss could never move.
445    #[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    /// A gradient of the wrong shape must be reported, not truncated.
475    #[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    /// Gradient accumulation must hold the parameters still until the window is
493    /// full, then apply the window mean.
494    #[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    /// The window mean, not the window sum, must be applied: three identical
518    /// gradients accumulated must move the parameters exactly as far as one
519    /// unaccumulated gradient of the same value.
520    #[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    /// The learning rate schedule must actually drive the optimizer.
556    #[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    /// The memory rationale behind each recommendation must be reported
589    /// truthfully: that is the whole basis on which the family is chosen.
590    #[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    /// The recommendation must follow the hardware constraint, not a constant.
604    #[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        // Very low power: optimizer state is the binding constraint.
621        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    /// A `Mixed` memory strategy must not lose the accumulation window.
662    #[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}