feat(batch11): WSD LR scheduler, KV CPU offloading, GQA KV head expansion
CI / Format Check (push) Failing after 9s
CI / Clippy Check (push) Failing after 19s
Documentation / Build API Documentation (push) Failing after 10s
GPU Tests / Check GPU Availability (push) Successful in 0s
Documentation / Build User Guide (push) Successful in 11s
Performance Benchmarks / Run Benchmarks (push) Failing after 38s
CI / Build (ubuntu-latest) (push) Failing after 54s
CI / Build CPU-Only (Explicit) (push) Failing after 1m6s
GPU Tests / CUDA Tests (11.8) (push) Has been skipped
GPU Tests / CUDA Tests (12.1) (push) Has been skipped
CI / Build (macos-latest) (push) Failing after 59s
CI / Test (macos-latest) (push) Has been skipped
CI / Test (ubuntu-latest) (push) Has been skipped
CI / Python Bindings (maturin) (macos-latest) (push) Has been skipped
CI / Python Bindings (maturin) (ubuntu-latest) (push) Has been skipped
CI / WASM Build + Size Check (push) Has been skipped
CI / Distributed Training Tests (push) Has been skipped
CI / CI Success (push) Failing after 0s
GPU Tests / Metal Tests (push) Has been skipped
CI / Format Check (push) Failing after 9s
CI / Clippy Check (push) Failing after 19s
Documentation / Build API Documentation (push) Failing after 10s
GPU Tests / Check GPU Availability (push) Successful in 0s
Documentation / Build User Guide (push) Successful in 11s
Performance Benchmarks / Run Benchmarks (push) Failing after 38s
CI / Build (ubuntu-latest) (push) Failing after 54s
CI / Build CPU-Only (Explicit) (push) Failing after 1m6s
GPU Tests / CUDA Tests (11.8) (push) Has been skipped
GPU Tests / CUDA Tests (12.1) (push) Has been skipped
CI / Build (macos-latest) (push) Failing after 59s
CI / Test (macos-latest) (push) Has been skipped
CI / Test (ubuntu-latest) (push) Has been skipped
CI / Python Bindings (maturin) (macos-latest) (push) Has been skipped
CI / Python Bindings (maturin) (ubuntu-latest) (push) Has been skipped
CI / WASM Build + Size Check (push) Has been skipped
CI / Distributed Training Tests (push) Has been skipped
CI / CI Success (push) Failing after 0s
GPU Tests / Metal Tests (push) Has been skipped
- WSD scheduler (Warmup-Stable-Decay / trapezoidal): linear warmup → constant plateau → cosine/linear/sqrt decay; extend_stable() adds steps mid-run without restart; phase_at()/decay_progress() introspection; 26 tests + 2 doctests - KV CPU offloading: KvCpuOffloadManager LRU-based GPU→CPU page spill with on-demand prefetch; insert() auto-offloads when at gpu_page_limit; stats() with hit rate and utilization; 14 tests - GQA KV head expansion: GqaConfig validates num_q_heads/num_kv_heads divisibility; expand_kv_heads() tiles KV [batch,kv_heads,seq,dim]→[batch,q_heads,seq,dim]; gqa_attention_cpu() with numerically stable softmax + causal mask; 15 tests Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
2360d7bffc
commit
4ae0c34537
@@ -12,6 +12,7 @@ pub mod polynomial_decay;
|
||||
pub mod reduce_lr_on_plateau;
|
||||
pub mod step_lr;
|
||||
pub mod warmup;
|
||||
pub mod wsd;
|
||||
|
||||
#[cfg(all(test, feature = "disabled_tests"))]
|
||||
pub mod scheduler_integration_tests;
|
||||
@@ -25,6 +26,7 @@ pub use reduce_lr_on_plateau::{
|
||||
};
|
||||
pub use step_lr::StepLRScheduler;
|
||||
pub use warmup::WarmupScheduler;
|
||||
pub use wsd::{WsdDecayType, WsdPhase, WsdScheduler};
|
||||
|
||||
/// Trait for learning rate schedulers
|
||||
pub trait LearningRateScheduler: Send + Sync {
|
||||
|
||||
@@ -0,0 +1,745 @@
|
||||
//! Warmup-Stable-Decay (WSD) learning rate scheduler
|
||||
//!
|
||||
//! Implements the trapezoidal LR schedule used by Mistral, MiniMax-Text-01, MegaMath,
|
||||
//! and other modern open-source LLMs. The schedule has three phases:
|
||||
//!
|
||||
//! 1. **Warmup** — linear ramp from `0` to `peak_lr` over `warmup_steps`
|
||||
//! 2. **Stable** — constant at `peak_lr` for `stable_steps` (extendable mid-training)
|
||||
//! 3. **Decay** — cosine, linear, or sqrt decay from `peak_lr` to `min_lr` over `decay_steps`
|
||||
//!
|
||||
//! # Key property
|
||||
//!
|
||||
//! `stable_steps` can be extended at any point during training (via [`WsdScheduler::extend_stable`])
|
||||
//! without resetting the scheduler or restarting training. This enables flexible compute budgets
|
||||
//! where you decide when to stop spending tokens on the plateau.
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust
|
||||
//! use rtx_transformers::schedulers::wsd::{WsdScheduler, WsdDecayType};
|
||||
//! use rtx_transformers::schedulers::LearningRateScheduler;
|
||||
//!
|
||||
//! let mut sched = WsdScheduler::cosine(3e-4, 1e-5, 100, 900, 200).unwrap();
|
||||
//!
|
||||
//! // Warmup: LR ramps up
|
||||
//! let lr_step0 = sched.get_lr(0, 0);
|
||||
//! assert!(lr_step0 < 3e-4);
|
||||
//!
|
||||
//! // Stable: LR stays at peak
|
||||
//! let lr_stable = sched.get_lr(0, 500);
|
||||
//! assert!((lr_stable - 3e-4).abs() < 1e-12);
|
||||
//!
|
||||
//! // Extend stable phase by 500 more steps mid-run
|
||||
//! sched.extend_stable(500);
|
||||
//! assert_eq!(sched.total_steps(), 100 + 1400 + 200);
|
||||
//! ```
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::schedulers::LearningRateScheduler;
|
||||
use crate::{Result, TransformerError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, trace};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decay variant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Decay function applied during the third (decay) phase.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum WsdDecayType {
|
||||
/// Cosine decay from `peak_lr` to `min_lr` (smoother, generally recommended).
|
||||
///
|
||||
/// `lr(p) = min_lr + (peak_lr - min_lr) * 0.5 * (1 + cos(π * p))`
|
||||
Cosine,
|
||||
/// Linear decay from `peak_lr` to `min_lr`.
|
||||
///
|
||||
/// `lr(p) = peak_lr + (min_lr - peak_lr) * p`
|
||||
Linear,
|
||||
/// Square-root decay from `peak_lr` to `min_lr`.
|
||||
///
|
||||
/// `lr(p) = min_lr + (peak_lr - min_lr) * (1 - sqrt(p))`
|
||||
Sqrt,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase enum
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Phase the scheduler is currently in, for external introspection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WsdPhase {
|
||||
/// Linear warmup — LR is still ramping up.
|
||||
Warmup,
|
||||
/// Plateau — LR is constant at `peak_lr`.
|
||||
Stable,
|
||||
/// Decay — LR is decreasing toward `min_lr`.
|
||||
Decay,
|
||||
/// Past the end of the schedule — LR is clamped to `min_lr`.
|
||||
Complete,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduler struct
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Warmup-Stable-Decay learning rate scheduler.
|
||||
///
|
||||
/// Three-phase schedule: linear warmup → constant plateau → cosine/linear/sqrt decay.
|
||||
///
|
||||
/// See the [module-level documentation](self) for a full description and usage example.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WsdScheduler {
|
||||
/// Target learning rate at the peak (end of warmup / entire stable phase).
|
||||
pub peak_lr: f64,
|
||||
/// Minimum learning rate at the end of the decay phase (and beyond).
|
||||
pub min_lr: f64,
|
||||
/// Number of steps in the linear warmup phase.
|
||||
pub warmup_steps: usize,
|
||||
/// Number of steps in the stable (constant) phase. Can be extended at any time.
|
||||
pub stable_steps: usize,
|
||||
/// Number of steps in the decay phase.
|
||||
pub decay_steps: usize,
|
||||
/// Which decay function to use during the third phase.
|
||||
pub decay_type: WsdDecayType,
|
||||
/// Internal step counter; advanced by [`Self::step`].
|
||||
current_step: usize,
|
||||
}
|
||||
|
||||
impl WsdScheduler {
|
||||
// ------------------------------------------------------------------
|
||||
// Constructors
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Create a new WSD scheduler with explicit parameters.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`TransformerError`] when:
|
||||
/// - `peak_lr` is not positive, or `peak_lr <= min_lr`
|
||||
/// - `min_lr` is negative
|
||||
/// - `warmup_steps` is zero
|
||||
/// - `decay_steps` is zero
|
||||
pub fn new(
|
||||
peak_lr: f64,
|
||||
min_lr: f64,
|
||||
warmup_steps: usize,
|
||||
stable_steps: usize,
|
||||
decay_steps: usize,
|
||||
decay_type: WsdDecayType,
|
||||
) -> Result<Self> {
|
||||
if peak_lr <= 0.0 {
|
||||
return Err(TransformerError::generic(format!(
|
||||
"peak_lr {peak_lr} must be positive"
|
||||
)));
|
||||
}
|
||||
if min_lr < 0.0 {
|
||||
return Err(TransformerError::generic(format!(
|
||||
"min_lr {min_lr} must be non-negative"
|
||||
)));
|
||||
}
|
||||
if peak_lr <= min_lr {
|
||||
return Err(TransformerError::generic(format!(
|
||||
"peak_lr {peak_lr} must be greater than min_lr {min_lr}"
|
||||
)));
|
||||
}
|
||||
if warmup_steps == 0 {
|
||||
return Err(TransformerError::generic(
|
||||
"warmup_steps must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
if decay_steps == 0 {
|
||||
return Err(TransformerError::generic(
|
||||
"decay_steps must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Creating WsdScheduler: peak_lr={peak_lr}, min_lr={min_lr}, \
|
||||
warmup={warmup_steps}, stable={stable_steps}, decay={decay_steps}, \
|
||||
decay_type={decay_type:?}"
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
peak_lr,
|
||||
min_lr,
|
||||
warmup_steps,
|
||||
stable_steps,
|
||||
decay_steps,
|
||||
decay_type,
|
||||
current_step: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convenience constructor that uses cosine decay (the most common choice).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates validation errors from [`Self::new`].
|
||||
pub fn cosine(
|
||||
peak_lr: f64,
|
||||
min_lr: f64,
|
||||
warmup_steps: usize,
|
||||
stable_steps: usize,
|
||||
decay_steps: usize,
|
||||
) -> Result<Self> {
|
||||
Self::new(
|
||||
peak_lr,
|
||||
min_lr,
|
||||
warmup_steps,
|
||||
stable_steps,
|
||||
decay_steps,
|
||||
WsdDecayType::Cosine,
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Mutation helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Extend the stable phase by `extra_steps` without resetting the scheduler.
|
||||
///
|
||||
/// This is the key flexibility property of WSD: you can decide mid-training
|
||||
/// to spend more compute at the peak learning rate before decaying.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use rtx_transformers::schedulers::wsd::WsdScheduler;
|
||||
///
|
||||
/// let mut sched = WsdScheduler::cosine(1e-3, 1e-5, 100, 500, 100).unwrap();
|
||||
/// assert_eq!(sched.total_steps(), 700);
|
||||
/// sched.extend_stable(200);
|
||||
/// assert_eq!(sched.total_steps(), 900);
|
||||
/// ```
|
||||
pub fn extend_stable(&mut self, extra_steps: usize) {
|
||||
self.stable_steps = self.stable_steps.saturating_add(extra_steps);
|
||||
debug!("Extended stable phase by {extra_steps}; new stable_steps={}", self.stable_steps);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Queries
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Total steps across all three phases.
|
||||
#[must_use]
|
||||
pub fn total_steps(&self) -> usize {
|
||||
self.warmup_steps
|
||||
.saturating_add(self.stable_steps)
|
||||
.saturating_add(self.decay_steps)
|
||||
}
|
||||
|
||||
/// Which phase the scheduler is in at `step`.
|
||||
///
|
||||
/// Uses the *configured* `stable_steps` (including any extensions).
|
||||
#[must_use]
|
||||
pub fn phase_at(&self, step: usize) -> WsdPhase {
|
||||
if step < self.warmup_steps {
|
||||
WsdPhase::Warmup
|
||||
} else if step < self.warmup_steps.saturating_add(self.stable_steps) {
|
||||
WsdPhase::Stable
|
||||
} else if step < self.total_steps() {
|
||||
WsdPhase::Decay
|
||||
} else {
|
||||
WsdPhase::Complete
|
||||
}
|
||||
}
|
||||
|
||||
/// Which phase the scheduler is currently in (based on `current_step`).
|
||||
#[must_use]
|
||||
pub fn current_phase(&self) -> WsdPhase {
|
||||
self.phase_at(self.current_step)
|
||||
}
|
||||
|
||||
/// LR at the current internal step.
|
||||
#[must_use]
|
||||
pub fn current_lr(&self) -> f64 {
|
||||
self.get_lr(0, self.current_step)
|
||||
}
|
||||
|
||||
/// Fraction through the decay phase at `step`, in `0.0..=1.0`.
|
||||
///
|
||||
/// Returns `0.0` for steps outside the decay phase.
|
||||
#[must_use]
|
||||
pub fn decay_progress_at(&self, step: usize) -> f64 {
|
||||
let decay_start = self.warmup_steps.saturating_add(self.stable_steps);
|
||||
if step < decay_start || step >= self.total_steps() {
|
||||
0.0
|
||||
} else {
|
||||
let t = step - decay_start;
|
||||
// Clamp to [0, 1] — last decay step maps to exactly 1.0
|
||||
(t as f64 / self.decay_steps as f64).min(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fraction through the decay phase at `current_step`.
|
||||
#[must_use]
|
||||
pub fn decay_progress(&self) -> f64 {
|
||||
self.decay_progress_at(self.current_step)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Core LR computation (pure, no mutation)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
fn compute_lr(&self, step: usize) -> f64 {
|
||||
let decay_start = self.warmup_steps.saturating_add(self.stable_steps);
|
||||
|
||||
if step < self.warmup_steps {
|
||||
// Phase 1: linear warmup — lr = peak_lr * (step + 1) / warmup_steps
|
||||
// Using (step + 1) so step=0 gives a non-zero but tiny LR, and
|
||||
// step = warmup_steps - 1 gives peak_lr * (warmup_steps / warmup_steps) = peak_lr.
|
||||
let progress = (step + 1) as f64 / self.warmup_steps as f64;
|
||||
let lr = self.peak_lr * progress;
|
||||
trace!("WSD warmup step={step}: progress={progress:.4}, lr={lr:.6e}");
|
||||
lr
|
||||
} else if step < decay_start {
|
||||
// Phase 2: stable plateau
|
||||
trace!("WSD stable step={step}: lr={:.6e}", self.peak_lr);
|
||||
self.peak_lr
|
||||
} else if step < self.total_steps() {
|
||||
// Phase 3: decay
|
||||
let t = step - decay_start;
|
||||
let p = (t as f64 / self.decay_steps as f64).clamp(0.0, 1.0);
|
||||
let lr = match self.decay_type {
|
||||
WsdDecayType::Cosine => {
|
||||
self.min_lr + (self.peak_lr - self.min_lr) * 0.5 * (1.0 + (PI * p).cos())
|
||||
}
|
||||
WsdDecayType::Linear => self.peak_lr + (self.min_lr - self.peak_lr) * p,
|
||||
WsdDecayType::Sqrt => {
|
||||
self.min_lr + (self.peak_lr - self.min_lr) * (1.0 - p.sqrt())
|
||||
}
|
||||
};
|
||||
trace!("WSD decay step={step}: p={p:.4}, lr={lr:.6e}");
|
||||
lr
|
||||
} else {
|
||||
// Phase 4: complete — clamp to min_lr
|
||||
trace!("WSD complete step={step}: lr={:.6e}", self.min_lr);
|
||||
self.min_lr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trait implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl LearningRateScheduler for WsdScheduler {
|
||||
/// Compute LR at the given `step` (the `epoch` parameter is ignored — WSD
|
||||
/// is step-based, consistent with how it is used in large LLM training).
|
||||
fn get_lr(&self, _epoch: usize, step: usize) -> f64 {
|
||||
self.compute_lr(step)
|
||||
}
|
||||
|
||||
/// Advance the internal step counter by one.
|
||||
fn step(&mut self) {
|
||||
self.current_step += 1;
|
||||
trace!("WSD scheduler stepped to {}", self.current_step);
|
||||
}
|
||||
|
||||
fn current_step(&self) -> usize {
|
||||
self.current_step
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.current_step = 0;
|
||||
debug!("Reset WsdScheduler");
|
||||
}
|
||||
|
||||
fn scheduler_type(&self) -> &'static str {
|
||||
"WarmupStableDecay"
|
||||
}
|
||||
|
||||
fn base_lr(&self) -> f64 {
|
||||
self.peak_lr
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PEAK: f64 = 3e-4;
|
||||
const MIN: f64 = 1e-5;
|
||||
const WARMUP: usize = 100;
|
||||
const STABLE: usize = 900;
|
||||
const DECAY: usize = 200;
|
||||
|
||||
fn default_sched() -> WsdScheduler {
|
||||
WsdScheduler::cosine(PEAK, MIN, WARMUP, STABLE, DECAY).unwrap()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Warmup phase
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_warmup_phase_starts_at_zero() {
|
||||
let s = default_sched();
|
||||
// step=0 gives peak_lr * 1/100, which is very small but non-zero
|
||||
let lr0 = s.get_lr(0, 0);
|
||||
assert!(lr0 > 0.0, "lr at step 0 should be > 0");
|
||||
assert!(lr0 < PEAK, "lr at step 0 should be less than peak_lr");
|
||||
|
||||
// step warmup_steps-1 should be strictly less than peak_lr
|
||||
let lr_last_warmup = s.get_lr(0, WARMUP - 1);
|
||||
// (100/100) * PEAK = PEAK — actually at WARMUP-1 = 99: progress = 100/100 = 1.0 → PEAK
|
||||
// Wait: step=99, (99+1)/100 = 100/100 = 1.0 → lr = PEAK
|
||||
// So last warmup step IS peak. Let's check step 98 instead.
|
||||
let lr_98 = s.get_lr(0, 98);
|
||||
assert!(lr_98 < PEAK);
|
||||
// At step 99 (last warmup step), progress = 100/100 = 1.0, so lr = PEAK
|
||||
assert!((lr_last_warmup - PEAK).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_warmup_phase_reaches_peak() {
|
||||
let s = default_sched();
|
||||
// First step of stable phase = warmup_steps (step == WARMUP)
|
||||
let lr = s.get_lr(0, WARMUP);
|
||||
assert!(
|
||||
(lr - PEAK).abs() < 1e-12,
|
||||
"LR at step=warmup_steps must equal peak_lr, got {lr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_warmup_linear_increase() {
|
||||
let s = default_sched();
|
||||
// LR must be monotonically increasing during warmup
|
||||
let mut prev = s.get_lr(0, 0);
|
||||
for step in 1..WARMUP {
|
||||
let cur = s.get_lr(0, step);
|
||||
assert!(
|
||||
cur >= prev,
|
||||
"warmup should be monotonically non-decreasing: step {step} lr={cur} < prev={prev}"
|
||||
);
|
||||
prev = cur;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Stable phase
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_stable_phase_constant() {
|
||||
let s = default_sched();
|
||||
for step in WARMUP..=(WARMUP + STABLE - 1) {
|
||||
let lr = s.get_lr(0, step);
|
||||
assert!(
|
||||
(lr - PEAK).abs() < 1e-12,
|
||||
"stable phase step {step}: expected {PEAK} got {lr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Decay phase — cosine
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_decay_cosine_at_start() {
|
||||
let s = default_sched();
|
||||
// First decay step: p = 1/200 = 0.005; cos(π * 0.005) is very close to 1.
|
||||
// lr ≈ peak_lr — the cosine has barely moved away from the peak.
|
||||
let decay_start = WARMUP + STABLE;
|
||||
let lr = s.get_lr(0, decay_start);
|
||||
assert!(
|
||||
(lr - PEAK).abs() < 1e-3,
|
||||
"first decay step should be ≈ peak_lr (within 1e-3), got {lr}"
|
||||
);
|
||||
// Must be less than or equal to peak_lr — the decay cannot overshoot.
|
||||
assert!(
|
||||
lr <= PEAK,
|
||||
"first decay step must not exceed peak_lr, got {lr}"
|
||||
);
|
||||
// And it must be greater than min_lr — we are only at the very start.
|
||||
assert!(
|
||||
lr > MIN,
|
||||
"first decay step must still be far above min_lr, got {lr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decay_cosine_at_end() {
|
||||
let s = default_sched();
|
||||
// Last decay step: step = total_steps - 1, p = (DECAY-1)/DECAY
|
||||
let last = s.total_steps() - 1;
|
||||
let lr = s.get_lr(0, last);
|
||||
// cos(π * (199/200)) ≈ -1 → lr ≈ min_lr
|
||||
assert!(
|
||||
(lr - MIN).abs() < 1e-3,
|
||||
"last decay step should be ≈ min_lr, got {lr}"
|
||||
);
|
||||
assert!(lr > MIN, "last decay step should be strictly greater than min_lr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decay_cosine_monotone_decreasing() {
|
||||
let s = default_sched();
|
||||
let decay_start = WARMUP + STABLE;
|
||||
let mut prev = s.get_lr(0, decay_start);
|
||||
for step in (decay_start + 1)..s.total_steps() {
|
||||
let cur = s.get_lr(0, step);
|
||||
assert!(
|
||||
cur <= prev + 1e-15,
|
||||
"cosine decay should be monotone non-increasing: step {step} lr={cur} > prev={prev}"
|
||||
);
|
||||
prev = cur;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decay_linear_midpoint() {
|
||||
let mut s = WsdScheduler::new(PEAK, MIN, WARMUP, STABLE, DECAY, WsdDecayType::Linear)
|
||||
.unwrap();
|
||||
// extend stable to 0 so midpoint is clearly mid-decay
|
||||
s.stable_steps = 0;
|
||||
let decay_start = WARMUP;
|
||||
let mid = decay_start + DECAY / 2;
|
||||
let lr = s.get_lr(0, mid);
|
||||
let expected = PEAK + (MIN - PEAK) * 0.5;
|
||||
assert!(
|
||||
(lr - expected).abs() < 1e-10,
|
||||
"linear decay midpoint: expected {expected}, got {lr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decay_sqrt_midpoint() {
|
||||
let s =
|
||||
WsdScheduler::new(PEAK, MIN, WARMUP, STABLE, DECAY, WsdDecayType::Sqrt).unwrap();
|
||||
let decay_start = WARMUP + STABLE;
|
||||
let mid = decay_start + DECAY / 2;
|
||||
let p = 0.5_f64;
|
||||
let lr = s.get_lr(0, mid);
|
||||
let expected = MIN + (PEAK - MIN) * (1.0 - p.sqrt());
|
||||
assert!(
|
||||
(lr - expected).abs() < 1e-8,
|
||||
"sqrt decay midpoint: expected {expected}, got {lr}"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Complete phase
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_complete_phase_returns_min_lr() {
|
||||
let s = default_sched();
|
||||
let total = s.total_steps();
|
||||
for step in [total, total + 1, total + 1000] {
|
||||
let lr = s.get_lr(0, step);
|
||||
assert!(
|
||||
(lr - MIN).abs() < 1e-12,
|
||||
"step {step} (past end) should return min_lr={MIN}, got {lr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// total_steps
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_total_steps() {
|
||||
let s = default_sched();
|
||||
assert_eq!(
|
||||
s.total_steps(),
|
||||
WARMUP + STABLE + DECAY,
|
||||
"total_steps() must equal warmup + stable + decay"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Phase introspection
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_current_phase_warmup() {
|
||||
let mut s = default_sched();
|
||||
// current_step starts at 0 → Warmup
|
||||
assert_eq!(s.current_phase(), WsdPhase::Warmup);
|
||||
assert_eq!(s.phase_at(0), WsdPhase::Warmup);
|
||||
assert_eq!(s.phase_at(WARMUP - 1), WsdPhase::Warmup);
|
||||
// Step past warmup
|
||||
for _ in 0..WARMUP {
|
||||
s.step();
|
||||
}
|
||||
assert_ne!(s.current_phase(), WsdPhase::Warmup);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_current_phase_stable() {
|
||||
let s = default_sched();
|
||||
assert_eq!(s.phase_at(WARMUP), WsdPhase::Stable);
|
||||
assert_eq!(s.phase_at(WARMUP + STABLE - 1), WsdPhase::Stable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_current_phase_decay() {
|
||||
let s = default_sched();
|
||||
let decay_start = WARMUP + STABLE;
|
||||
assert_eq!(s.phase_at(decay_start), WsdPhase::Decay);
|
||||
assert_eq!(s.phase_at(decay_start + DECAY - 1), WsdPhase::Decay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_current_phase_complete() {
|
||||
let s = default_sched();
|
||||
assert_eq!(s.phase_at(s.total_steps()), WsdPhase::Complete);
|
||||
assert_eq!(s.phase_at(s.total_steps() + 999), WsdPhase::Complete);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// extend_stable
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_extend_stable_delays_decay() {
|
||||
let mut s = default_sched();
|
||||
let original_total = s.total_steps();
|
||||
s.extend_stable(100);
|
||||
assert_eq!(s.stable_steps, STABLE + 100);
|
||||
assert_eq!(s.total_steps(), original_total + 100);
|
||||
|
||||
// The step that used to be the first decay step is now still stable
|
||||
let old_decay_start = WARMUP + STABLE;
|
||||
assert_eq!(
|
||||
s.phase_at(old_decay_start),
|
||||
WsdPhase::Stable,
|
||||
"old decay start should now be in Stable after extension"
|
||||
);
|
||||
// New decay start
|
||||
let new_decay_start = WARMUP + STABLE + 100;
|
||||
assert_eq!(s.phase_at(new_decay_start), WsdPhase::Decay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extend_stable_lr_still_peak_at_extended_steps() {
|
||||
let mut s = default_sched();
|
||||
s.extend_stable(300);
|
||||
// Any step in [WARMUP, WARMUP + STABLE + 300) should return PEAK
|
||||
for step in [WARMUP, WARMUP + STABLE, WARMUP + STABLE + 299] {
|
||||
let lr = s.get_lr(0, step);
|
||||
assert!(
|
||||
(lr - PEAK).abs() < 1e-12,
|
||||
"extended stable step {step} should return peak_lr, got {lr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// decay_progress
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_decay_progress_zero_in_stable() {
|
||||
let s = default_sched();
|
||||
// During warmup and stable, decay_progress should be 0.0
|
||||
for step in [0, WARMUP / 2, WARMUP, WARMUP + STABLE - 1] {
|
||||
assert_eq!(
|
||||
s.decay_progress_at(step),
|
||||
0.0,
|
||||
"step {step} is not in decay, progress should be 0.0"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decay_progress_one_at_end() {
|
||||
let s = default_sched();
|
||||
// Last decay step: step = total_steps - 1
|
||||
// t = total_steps - 1 - (warmup + stable) = DECAY - 1
|
||||
// p = (DECAY - 1) / DECAY
|
||||
let last_decay = s.total_steps() - 1;
|
||||
let p = s.decay_progress_at(last_decay);
|
||||
let expected = (DECAY - 1) as f64 / DECAY as f64;
|
||||
assert!(
|
||||
(p - expected).abs() < 1e-12,
|
||||
"decay progress at last decay step: expected {expected}, got {p}"
|
||||
);
|
||||
// And complete phase (past total_steps) gives 0.0 (outside decay)
|
||||
assert_eq!(s.decay_progress_at(s.total_steps()), 0.0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Trait method wiring
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_step_advances_current_step() {
|
||||
let mut s = default_sched();
|
||||
assert_eq!(s.current_step(), 0);
|
||||
s.step();
|
||||
assert_eq!(s.current_step(), 1);
|
||||
s.step();
|
||||
assert_eq!(s.current_step(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_returns_to_zero() {
|
||||
let mut s = default_sched();
|
||||
for _ in 0..500 {
|
||||
s.step();
|
||||
}
|
||||
assert_eq!(s.current_step(), 500);
|
||||
s.reset();
|
||||
assert_eq!(s.current_step(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scheduler_type_str() {
|
||||
let s = default_sched();
|
||||
assert_eq!(s.scheduler_type(), "WarmupStableDecay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_base_lr_returns_peak() {
|
||||
let s = default_sched();
|
||||
assert_eq!(s.base_lr(), PEAK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_current_lr_matches_get_lr() {
|
||||
let mut s = default_sched();
|
||||
for _ in 0..750 {
|
||||
s.step();
|
||||
}
|
||||
assert!((s.current_lr() - s.get_lr(0, s.current_step())).abs() < 1e-15);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Validation
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_invalid_params_rejected() {
|
||||
// negative peak_lr
|
||||
assert!(WsdScheduler::cosine(-1e-3, 1e-5, 100, 100, 100).is_err());
|
||||
// zero peak_lr
|
||||
assert!(WsdScheduler::cosine(0.0, 1e-5, 100, 100, 100).is_err());
|
||||
// peak_lr <= min_lr
|
||||
assert!(WsdScheduler::cosine(1e-5, 1e-5, 100, 100, 100).is_err());
|
||||
assert!(WsdScheduler::cosine(1e-6, 1e-5, 100, 100, 100).is_err());
|
||||
// negative min_lr
|
||||
assert!(WsdScheduler::cosine(1e-3, -1.0, 100, 100, 100).is_err());
|
||||
// zero warmup_steps
|
||||
assert!(WsdScheduler::cosine(1e-3, 1e-5, 0, 100, 100).is_err());
|
||||
// zero decay_steps
|
||||
assert!(WsdScheduler::cosine(1e-3, 1e-5, 100, 100, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_stable_steps_allowed() {
|
||||
// stable_steps = 0 is valid (warmup directly into decay)
|
||||
let s = WsdScheduler::cosine(PEAK, MIN, WARMUP, 0, DECAY).unwrap();
|
||||
assert_eq!(s.total_steps(), WARMUP + DECAY);
|
||||
assert_eq!(s.phase_at(WARMUP), WsdPhase::Decay);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user