feat(batch12): online quant calibration, draft distillation loss, gradient noise scale
Documentation / Build User Guide (push) Successful in 12s
CI / Build (ubuntu-latest) (push) Failing after 1m3s
CI / Format Check (push) Failing after 19s
CI / Clippy Check (push) Failing after 18s
Documentation / Build API Documentation (push) Failing after 35s
CI / Build CPU-Only (Explicit) (push) Failing after 1m17s
Performance Benchmarks / Run Benchmarks (push) Successful in 2m10s
CI / Build (macos-latest) (push) Failing after 57s
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 1s
Documentation / Build User Guide (push) Successful in 12s
CI / Build (ubuntu-latest) (push) Failing after 1m3s
CI / Format Check (push) Failing after 19s
CI / Clippy Check (push) Failing after 18s
Documentation / Build API Documentation (push) Failing after 35s
CI / Build CPU-Only (Explicit) (push) Failing after 1m17s
Performance Benchmarks / Run Benchmarks (push) Successful in 2m10s
CI / Build (macos-latest) (push) Failing after 57s
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 1s
- Online quantization calibration (rtx-compress): OnlineCalibrator with MaxAbs,
EmaMaxAbs{momentum}, Percentile{percentile,bins} methods; streaming observe();
quantize_int8/dequantize_int8; int8_maxabs/int8_ema/fp8_maxabs convenience ctors;
ModelCalibrator tracks all tensors; 17 tests + 2 doctests
- Draft distillation loss (rtx-transformers): KL(p_target‖p_draft) + CE hard-label
with temperature scaling; log_softmax/softmax/kl_divergence/token_acceptance_estimate
primitives; DistillAccumulator for epoch-level tracking; normalize_by_length;
13 tests + 6 doctests
- Gradient noise scale (rtx-transformers): GradientNoiseScale with McCandlish 2018
two-point B_noise estimator + Welford single-pass mode; EMA smoothing; should_increase/
decrease_batch signals; GnsTracker with bounded history + trend detection;
16 tests + 2 doctests
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
3ec300af6b
commit
924c237096
@@ -3,6 +3,7 @@ pub mod int8_matmul;
|
|||||||
pub mod microscaling;
|
pub mod microscaling;
|
||||||
pub mod mixed_precision;
|
pub mod mixed_precision;
|
||||||
pub mod mx_gpu_kernels;
|
pub mod mx_gpu_kernels;
|
||||||
|
pub mod online_calib;
|
||||||
pub mod post_training;
|
pub mod post_training;
|
||||||
pub mod product_quantization;
|
pub mod product_quantization;
|
||||||
pub mod qat;
|
pub mod qat;
|
||||||
@@ -41,6 +42,9 @@ pub use w4a16_matmul::{
|
|||||||
w4a16_matmul_cpu,
|
w4a16_matmul_cpu,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Online calibration exports
|
||||||
|
pub use online_calib::{CalibMethod, ModelCalibrator, OnlineCalibrator};
|
||||||
|
|
||||||
// INT8 GEMM exports
|
// INT8 GEMM exports
|
||||||
pub use int8_matmul::{int8_gemm, int8_matvec};
|
pub use int8_matmul::{int8_gemm, int8_matvec};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,611 @@
|
|||||||
|
//! Online quantization calibration.
|
||||||
|
//!
|
||||||
|
//! Tracks running tensor statistics (EMA of amax, running percentile via
|
||||||
|
//! histogram) during actual inference or early training steps, eliminating the
|
||||||
|
//! need for a separate offline calibration pass.
|
||||||
|
//!
|
||||||
|
//! This follows the approach used by TensorRT-LLM and NVIDIA Transformer
|
||||||
|
//! Engine for INT8/FP8 deployment.
|
||||||
|
//!
|
||||||
|
//! # Example
|
||||||
|
//!
|
||||||
|
//! ```rust
|
||||||
|
//! use rtx_compress::quantization::online_calib::{CalibMethod, OnlineCalibrator, ModelCalibrator};
|
||||||
|
//!
|
||||||
|
//! // Single-tensor INT8 calibration via EMA
|
||||||
|
//! let mut calib = OnlineCalibrator::int8_ema("layer0.weight", 0.999);
|
||||||
|
//! calib.observe(&[0.1, -0.5, 0.3, 0.9]);
|
||||||
|
//! calib.observe(&[0.2, -0.4, 0.8, 0.1]);
|
||||||
|
//! let scale = calib.scale().expect("at least one observation required");
|
||||||
|
//!
|
||||||
|
//! // Model-wide calibration
|
||||||
|
//! let mut model_calib = ModelCalibrator::new(CalibMethod::MaxAbs, 127.0);
|
||||||
|
//! model_calib.observe("fc1.weight", &[0.1, -0.9, 0.5]);
|
||||||
|
//! model_calib.observe("fc2.weight", &[-0.3, 0.7, 0.2]);
|
||||||
|
//! let scales = model_calib.export_scales();
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
// ─── CalibMethod ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Method for computing the quantization scale from statistics.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum CalibMethod {
|
||||||
|
/// Track running maximum absolute value; `scale = amax / quant_max`.
|
||||||
|
MaxAbs,
|
||||||
|
/// Exponential moving average of amax; `scale = ema_amax / quant_max`.
|
||||||
|
///
|
||||||
|
/// `momentum` is the decay factor applied to the historical EMA value
|
||||||
|
/// (typical: 0.999). A new observation `a` updates the EMA as:
|
||||||
|
/// `ema = momentum * ema + (1 - momentum) * a`.
|
||||||
|
EmaMaxAbs {
|
||||||
|
/// Decay factor in `(0, 1)`. Higher values give more weight to
|
||||||
|
/// historical observations. Typical: 0.999.
|
||||||
|
momentum: f32,
|
||||||
|
},
|
||||||
|
/// Running percentile via a fixed-width histogram; avoids outlier
|
||||||
|
/// sensitivity.
|
||||||
|
///
|
||||||
|
/// Absolute values are bucketed into `bins` bins over `[0, running_max]`.
|
||||||
|
/// The effective amax is the upper edge of the bin at which the cumulative
|
||||||
|
/// count first reaches `percentile / 100` of total observations.
|
||||||
|
Percentile {
|
||||||
|
/// Target percentile in `(0, 100]`. Typical: 99.9.
|
||||||
|
percentile: f32,
|
||||||
|
/// Number of histogram bins. Typical: 512.
|
||||||
|
bins: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── OnlineCalibrator ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Online calibrator for a single tensor (one weight or activation).
|
||||||
|
///
|
||||||
|
/// Call [`observe`](OnlineCalibrator::observe) once per forward pass (or
|
||||||
|
/// inference step) to accumulate statistics, then call
|
||||||
|
/// [`scale`](OnlineCalibrator::scale) to obtain the quantization scale that
|
||||||
|
/// maps the observed range into `[-quant_max, quant_max]`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OnlineCalibrator {
|
||||||
|
/// Human-readable name, e.g. `"model.layers.0.self_attn.q_proj"`.
|
||||||
|
pub name: String,
|
||||||
|
/// Calibration method used to derive the scale.
|
||||||
|
pub method: CalibMethod,
|
||||||
|
/// Quantization max value: 127.0 for INT8, 448.0 for FP8 E4M3.
|
||||||
|
pub quant_max: f32,
|
||||||
|
/// Total number of `observe()` calls made so far.
|
||||||
|
pub num_observations: usize,
|
||||||
|
|
||||||
|
// --- internal state --------------------------------------------------------
|
||||||
|
/// Running absolute maximum across all observations (MaxAbs / Percentile).
|
||||||
|
running_amax: f32,
|
||||||
|
/// Current EMA of amax (EmaMaxAbs only).
|
||||||
|
ema_amax: f32,
|
||||||
|
/// Histogram bin counts (Percentile only). Length == `bins`.
|
||||||
|
histogram: Vec<u64>,
|
||||||
|
/// Lower edge of the histogram range (always 0.0 for one-sided abs).
|
||||||
|
hist_min: f32,
|
||||||
|
/// Upper edge of the histogram range (== running_amax when initialised).
|
||||||
|
hist_max: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OnlineCalibrator {
|
||||||
|
// ── Constructors ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Create a new calibrator with explicit parameters.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if `quant_max <= 0.0`.
|
||||||
|
pub fn new(name: impl Into<String>, method: CalibMethod, quant_max: f32) -> Self {
|
||||||
|
assert!(quant_max > 0.0, "quant_max must be positive");
|
||||||
|
let bins = match &method {
|
||||||
|
CalibMethod::Percentile { bins, .. } => *bins,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
method,
|
||||||
|
quant_max,
|
||||||
|
num_observations: 0,
|
||||||
|
running_amax: 0.0,
|
||||||
|
ema_amax: 0.0,
|
||||||
|
histogram: vec![0u64; bins],
|
||||||
|
hist_min: 0.0,
|
||||||
|
hist_max: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: INT8 MaxAbs calibrator (`quant_max = 127.0`).
|
||||||
|
pub fn int8_maxabs(name: impl Into<String>) -> Self {
|
||||||
|
Self::new(name, CalibMethod::MaxAbs, 127.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: INT8 EMA calibrator (`quant_max = 127.0`).
|
||||||
|
pub fn int8_ema(name: impl Into<String>, momentum: f32) -> Self {
|
||||||
|
Self::new(name, CalibMethod::EmaMaxAbs { momentum }, 127.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: FP8 E4M3 MaxAbs calibrator (`quant_max = 448.0`).
|
||||||
|
pub fn fp8_maxabs(name: impl Into<String>) -> Self {
|
||||||
|
Self::new(name, CalibMethod::MaxAbs, 448.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Observe a batch of tensor values and update running statistics.
|
||||||
|
///
|
||||||
|
/// Absolute values are used internally; signs are discarded because
|
||||||
|
/// symmetric quantization uses a single scale.
|
||||||
|
///
|
||||||
|
/// Calling with an empty slice is a no-op.
|
||||||
|
pub fn observe(&mut self, data: &[f32]) {
|
||||||
|
if data.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute absolute maximum for this batch.
|
||||||
|
let batch_amax = data.iter().map(|v| v.abs()).fold(0.0f32, f32::max);
|
||||||
|
|
||||||
|
self.num_observations += 1;
|
||||||
|
|
||||||
|
match &self.method.clone() {
|
||||||
|
CalibMethod::MaxAbs => {
|
||||||
|
self.running_amax = self.running_amax.max(batch_amax);
|
||||||
|
}
|
||||||
|
CalibMethod::EmaMaxAbs { momentum } => {
|
||||||
|
let m = *momentum;
|
||||||
|
if self.num_observations == 1 {
|
||||||
|
// Bootstrap: first observation initialises both fields.
|
||||||
|
self.ema_amax = batch_amax;
|
||||||
|
self.running_amax = batch_amax;
|
||||||
|
} else {
|
||||||
|
self.ema_amax = m * self.ema_amax + (1.0 - m) * batch_amax;
|
||||||
|
self.running_amax = self.running_amax.max(batch_amax);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CalibMethod::Percentile { bins, .. } => {
|
||||||
|
let bins = *bins;
|
||||||
|
self.running_amax = self.running_amax.max(batch_amax);
|
||||||
|
|
||||||
|
if self.num_observations == 1 {
|
||||||
|
// Initialise histogram bounds on first call.
|
||||||
|
self.hist_min = 0.0;
|
||||||
|
self.hist_max = if batch_amax > 0.0 {
|
||||||
|
batch_amax
|
||||||
|
} else {
|
||||||
|
1.0 // guard against all-zero first batch
|
||||||
|
};
|
||||||
|
self.histogram = vec![0u64; bins];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If new data exceeds current bounds, reset and rebuild with
|
||||||
|
// the expanded range. We lose prior counts but maintain a
|
||||||
|
// valid histogram shape going forward.
|
||||||
|
if batch_amax > self.hist_max {
|
||||||
|
self.hist_max = batch_amax;
|
||||||
|
self.histogram = vec![0u64; bins];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bucket all absolute values from this batch.
|
||||||
|
let range = self.hist_max - self.hist_min; // hist_min == 0
|
||||||
|
for &v in data {
|
||||||
|
let abs_v = v.abs();
|
||||||
|
if abs_v <= self.hist_max {
|
||||||
|
let idx = if range > 0.0 {
|
||||||
|
((abs_v / range) * bins as f32) as usize
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let idx = idx.min(bins - 1);
|
||||||
|
self.histogram[idx] += 1;
|
||||||
|
}
|
||||||
|
// Values > hist_max were already handled by the range
|
||||||
|
// expansion above, so this branch should not be reached.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the current scale from accumulated statistics.
|
||||||
|
///
|
||||||
|
/// Returns `None` if no observations have been made yet.
|
||||||
|
///
|
||||||
|
/// The scale `s` satisfies: `quantized = clamp(round(x / s), -quant_max, quant_max)`.
|
||||||
|
pub fn scale(&self) -> Option<f32> {
|
||||||
|
if self.num_observations == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let effective_amax = match &self.method {
|
||||||
|
CalibMethod::MaxAbs => self.running_amax,
|
||||||
|
CalibMethod::EmaMaxAbs { .. } => self.ema_amax,
|
||||||
|
CalibMethod::Percentile { percentile, bins } => {
|
||||||
|
self.percentile_amax(*percentile, *bins)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if effective_amax <= 0.0 {
|
||||||
|
// All zeros observed — return a unit scale to avoid division by zero.
|
||||||
|
return Some(1.0 / self.quant_max);
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(effective_amax / self.quant_max)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute quantized zero-point.
|
||||||
|
///
|
||||||
|
/// Always returns 0 because symmetric quantization is used.
|
||||||
|
#[inline]
|
||||||
|
pub fn zero_point(&self) -> i32 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the calibrated scale to quantize `data` into INT8 range `[-128, 127]`.
|
||||||
|
///
|
||||||
|
/// Returns `None` if no observations have been made.
|
||||||
|
pub fn quantize_int8(&self, data: &[f32]) -> Option<Vec<i8>> {
|
||||||
|
let s = self.scale()?;
|
||||||
|
if s == 0.0 {
|
||||||
|
return Some(vec![0i8; data.len()]);
|
||||||
|
}
|
||||||
|
Some(
|
||||||
|
data.iter()
|
||||||
|
.map(|&v| {
|
||||||
|
let q = (v / s).round();
|
||||||
|
q.clamp(-128.0, 127.0) as i8
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dequantize INT8 back to f32 using the current scale.
|
||||||
|
///
|
||||||
|
/// `scale()` must have returned `Some` (i.e. at least one observation)
|
||||||
|
/// for the result to be meaningful; otherwise the scale defaults to
|
||||||
|
/// `1.0 / quant_max`.
|
||||||
|
pub fn dequantize_int8(&self, data: &[i8]) -> Vec<f32> {
|
||||||
|
let s = self.scale().unwrap_or(1.0 / self.quant_max);
|
||||||
|
data.iter().map(|&q| q as f32 * s).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset all accumulated statistics.
|
||||||
|
///
|
||||||
|
/// After calling `reset()`, `scale()` returns `None` again.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.num_observations = 0;
|
||||||
|
self.running_amax = 0.0;
|
||||||
|
self.ema_amax = 0.0;
|
||||||
|
for b in &mut self.histogram {
|
||||||
|
*b = 0;
|
||||||
|
}
|
||||||
|
self.hist_min = 0.0;
|
||||||
|
self.hist_max = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw running amax (before EMA smoothing if applicable).
|
||||||
|
#[inline]
|
||||||
|
pub fn current_amax(&self) -> f32 {
|
||||||
|
self.running_amax
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Walk the histogram to find the upper edge of the bin at which the
|
||||||
|
/// cumulative count first reaches `percentile`% of total observations.
|
||||||
|
fn percentile_amax(&self, percentile: f32, bins: usize) -> f32 {
|
||||||
|
let total: u64 = self.histogram.iter().sum();
|
||||||
|
if total == 0 || bins == 0 {
|
||||||
|
return self.running_amax;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp percentile to (0, 100].
|
||||||
|
let target_frac = (percentile / 100.0).clamp(0.0, 1.0);
|
||||||
|
let target_count = (target_frac * total as f32).ceil() as u64;
|
||||||
|
|
||||||
|
let mut cumulative: u64 = 0;
|
||||||
|
let bin_width = (self.hist_max - self.hist_min) / bins as f32;
|
||||||
|
|
||||||
|
for (i, &count) in self.histogram.iter().enumerate() {
|
||||||
|
cumulative += count;
|
||||||
|
if cumulative >= target_count {
|
||||||
|
// Upper edge of bin i.
|
||||||
|
return self.hist_min + (i + 1) as f32 * bin_width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All bins exhausted — return hist_max.
|
||||||
|
self.hist_max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ModelCalibrator ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Manages online calibrators for all named tensors in a model.
|
||||||
|
///
|
||||||
|
/// On the first call to [`observe`](ModelCalibrator::observe) for a given
|
||||||
|
/// tensor name, a new [`OnlineCalibrator`] is created automatically with the
|
||||||
|
/// shared `method` and `quant_max`.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// use rtx_compress::quantization::online_calib::{CalibMethod, ModelCalibrator};
|
||||||
|
///
|
||||||
|
/// let mut mc = ModelCalibrator::new(CalibMethod::MaxAbs, 127.0);
|
||||||
|
/// mc.observe("layer0.weight", &[0.5, -0.9, 0.3]);
|
||||||
|
/// mc.observe("layer1.weight", &[0.1, -0.2, 0.8]);
|
||||||
|
///
|
||||||
|
/// assert_eq!(mc.num_tensors(), 2);
|
||||||
|
/// let scales = mc.export_scales();
|
||||||
|
/// assert!(scales.contains_key("layer0.weight"));
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ModelCalibrator {
|
||||||
|
calibrators: HashMap<String, OnlineCalibrator>,
|
||||||
|
method: CalibMethod,
|
||||||
|
quant_max: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelCalibrator {
|
||||||
|
/// Create a new model-level calibrator.
|
||||||
|
///
|
||||||
|
/// `method` and `quant_max` are shared across all per-tensor calibrators
|
||||||
|
/// created by this instance.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if `quant_max <= 0.0`.
|
||||||
|
pub fn new(method: CalibMethod, quant_max: f32) -> Self {
|
||||||
|
assert!(quant_max > 0.0, "quant_max must be positive");
|
||||||
|
Self {
|
||||||
|
calibrators: HashMap::new(),
|
||||||
|
method,
|
||||||
|
quant_max,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observe a named tensor, creating a calibrator on first call.
|
||||||
|
pub fn observe(&mut self, name: &str, data: &[f32]) {
|
||||||
|
let method = self.method.clone();
|
||||||
|
let quant_max = self.quant_max;
|
||||||
|
let calib = self
|
||||||
|
.calibrators
|
||||||
|
.entry(name.to_string())
|
||||||
|
.or_insert_with(|| OnlineCalibrator::new(name, method, quant_max));
|
||||||
|
calib.observe(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the calibrated scale for a named tensor.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the tensor has never been observed.
|
||||||
|
pub fn scale_for(&self, name: &str) -> Option<f32> {
|
||||||
|
self.calibrators.get(name)?.scale()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of distinct tensor names being tracked.
|
||||||
|
pub fn num_tensors(&self) -> usize {
|
||||||
|
self.calibrators.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Names of all tensors for which at least one observation has been made.
|
||||||
|
pub fn tensor_names(&self) -> Vec<&str> {
|
||||||
|
self.calibrators.keys().map(String::as_str).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export all calibrated scales as a `HashMap<name, scale>`.
|
||||||
|
///
|
||||||
|
/// Tensors that have been observed but whose `scale()` returns `None`
|
||||||
|
/// (should not occur in practice) are omitted.
|
||||||
|
pub fn export_scales(&self) -> HashMap<String, f32> {
|
||||||
|
self.calibrators
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(name, calib)| calib.scale().map(|s| (name.clone(), s)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// Floating-point comparison helper.
|
||||||
|
fn approx_eq(a: f32, b: f32, tol: f32) -> bool {
|
||||||
|
(a - b).abs() <= tol
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OnlineCalibrator ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_maxabs_single_observe() {
|
||||||
|
let mut c = OnlineCalibrator::int8_maxabs("t");
|
||||||
|
c.observe(&[1.0, 2.0, 3.0]);
|
||||||
|
let s = c.scale().expect("should have scale after observe");
|
||||||
|
assert!(approx_eq(s, 3.0 / 127.0, 1e-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_maxabs_accumulates_across_calls() {
|
||||||
|
let mut c = OnlineCalibrator::int8_maxabs("t");
|
||||||
|
c.observe(&[1.0, 2.0]);
|
||||||
|
c.observe(&[5.0, 0.5]);
|
||||||
|
// Overall max is 5.0
|
||||||
|
let s = c.scale().unwrap();
|
||||||
|
assert!(approx_eq(s, 5.0 / 127.0, 1e-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ema_smooths_spike() {
|
||||||
|
// Large spike on first observation, then many small values.
|
||||||
|
// EMA with high momentum should end up much less than the spike.
|
||||||
|
let mut c = OnlineCalibrator::int8_ema("t", 0.999);
|
||||||
|
c.observe(&[100.0]); // spike — bootstraps ema_amax = 100
|
||||||
|
for _ in 0..500 {
|
||||||
|
c.observe(&[1.0]);
|
||||||
|
}
|
||||||
|
let s = c.scale().unwrap();
|
||||||
|
// After 500 steps with momentum 0.999, EMA decays substantially.
|
||||||
|
// 100 * 0.999^500 ≈ 60.6, but then we add 0.001 * 1 each step,
|
||||||
|
// converging toward 1.0. The resulting scale should be << 100/127.
|
||||||
|
assert!(s < 100.0 / 127.0, "EMA scale {s} should be < spike/quant_max");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_percentile_ignores_outlier() {
|
||||||
|
// 999 values near 1.0 and one extreme outlier at 1000.0.
|
||||||
|
// At 99.9th percentile the outlier should be excluded.
|
||||||
|
let mut c = OnlineCalibrator::new(
|
||||||
|
"t",
|
||||||
|
CalibMethod::Percentile {
|
||||||
|
percentile: 99.9,
|
||||||
|
bins: 512,
|
||||||
|
},
|
||||||
|
127.0,
|
||||||
|
);
|
||||||
|
let mut data: Vec<f32> = std::iter::repeat(1.0f32).take(999).collect();
|
||||||
|
data.push(1000.0); // single outlier
|
||||||
|
c.observe(&data);
|
||||||
|
let s = c.scale().unwrap();
|
||||||
|
// The effective amax should be close to 1.0, not 1000.0.
|
||||||
|
// Allow generous tolerance due to bin discretisation.
|
||||||
|
assert!(
|
||||||
|
s < 10.0 / 127.0,
|
||||||
|
"percentile scale {s} should be near 1/127, not 1000/127"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scale_none_before_observe() {
|
||||||
|
let c = OnlineCalibrator::int8_maxabs("t");
|
||||||
|
assert_eq!(c.scale(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quantize_int8_range() {
|
||||||
|
let mut c = OnlineCalibrator::int8_maxabs("t");
|
||||||
|
c.observe(&[-5.0, 5.0, 0.0]);
|
||||||
|
let large: Vec<f32> = (-200i32..=200).map(|i| i as f32 * 0.1).collect();
|
||||||
|
let q = c.quantize_int8(&large).unwrap();
|
||||||
|
for &v in &q {
|
||||||
|
assert!(v >= i8::MIN && v <= i8::MAX, "quantized value {v} out of i8 range");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quantize_dequantize_roundtrip() {
|
||||||
|
let mut c = OnlineCalibrator::int8_maxabs("t");
|
||||||
|
let data: Vec<f32> = (0..=10).map(|i| i as f32 * 0.1).collect();
|
||||||
|
c.observe(&data);
|
||||||
|
let scale = c.scale().unwrap();
|
||||||
|
let q = c.quantize_int8(&data).unwrap();
|
||||||
|
let dq = c.dequantize_int8(&q);
|
||||||
|
// Round-trip error should be at most one LSB == scale.
|
||||||
|
for (&orig, &reconstructed) in data.iter().zip(dq.iter()) {
|
||||||
|
assert!(
|
||||||
|
(orig - reconstructed).abs() <= scale + 1e-5,
|
||||||
|
"round-trip error too large: orig={orig}, reconstructed={reconstructed}, scale={scale}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reset_clears_state() {
|
||||||
|
let mut c = OnlineCalibrator::int8_maxabs("t");
|
||||||
|
c.observe(&[1.0, 2.0, 3.0]);
|
||||||
|
assert!(c.scale().is_some());
|
||||||
|
c.reset();
|
||||||
|
assert_eq!(c.scale(), None);
|
||||||
|
assert_eq!(c.num_observations, 0);
|
||||||
|
assert_eq!(c.current_amax(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_int8_convenience_ctor() {
|
||||||
|
let c = OnlineCalibrator::int8_maxabs("t");
|
||||||
|
assert_eq!(c.quant_max, 127.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fp8_convenience_ctor() {
|
||||||
|
let c = OnlineCalibrator::fp8_maxabs("t");
|
||||||
|
assert_eq!(c.quant_max, 448.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_zero_point_always_zero() {
|
||||||
|
let mut c = OnlineCalibrator::int8_maxabs("t");
|
||||||
|
c.observe(&[1.0]);
|
||||||
|
assert_eq!(c.zero_point(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ema_bootstraps_on_first_observation() {
|
||||||
|
// After exactly one observation, EMA should equal that observation.
|
||||||
|
let mut c = OnlineCalibrator::int8_ema("t", 0.9);
|
||||||
|
c.observe(&[7.0]);
|
||||||
|
// ema_amax bootstrapped to 7.0 → scale = 7/127
|
||||||
|
let s = c.scale().unwrap();
|
||||||
|
assert!(approx_eq(s, 7.0 / 127.0, 1e-5));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ModelCalibrator ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_model_calibrator_observe_multiple() {
|
||||||
|
let mut mc = ModelCalibrator::new(CalibMethod::MaxAbs, 127.0);
|
||||||
|
mc.observe("a", &[1.0]);
|
||||||
|
mc.observe("b", &[2.0]);
|
||||||
|
mc.observe("c", &[3.0]);
|
||||||
|
assert_eq!(mc.num_tensors(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_model_calibrator_export_scales() {
|
||||||
|
let mut mc = ModelCalibrator::new(CalibMethod::MaxAbs, 127.0);
|
||||||
|
mc.observe("w1", &[0.5, -0.9]);
|
||||||
|
mc.observe("w2", &[0.1, -0.3]);
|
||||||
|
let scales = mc.export_scales();
|
||||||
|
assert!(scales.contains_key("w1"));
|
||||||
|
assert!(scales.contains_key("w2"));
|
||||||
|
assert!(approx_eq(scales["w1"], 0.9 / 127.0, 1e-6));
|
||||||
|
assert!(approx_eq(scales["w2"], 0.3 / 127.0, 1e-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_model_calibrator_creates_on_first_observe() {
|
||||||
|
let mut mc = ModelCalibrator::new(CalibMethod::MaxAbs, 127.0);
|
||||||
|
assert_eq!(mc.num_tensors(), 0);
|
||||||
|
mc.observe("new_tensor", &[1.0]);
|
||||||
|
assert_eq!(mc.num_tensors(), 1);
|
||||||
|
assert!(mc.scale_for("new_tensor").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_model_calibrator_unknown_tensor_returns_none() {
|
||||||
|
let mc = ModelCalibrator::new(CalibMethod::MaxAbs, 127.0);
|
||||||
|
assert_eq!(mc.scale_for("does_not_exist"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_percentile_multiple_batches() {
|
||||||
|
// Observe the same uniform distribution across two batches.
|
||||||
|
// With 99.9th percentile the scale should still be near max/quant_max.
|
||||||
|
let mut c = OnlineCalibrator::new(
|
||||||
|
"t",
|
||||||
|
CalibMethod::Percentile {
|
||||||
|
percentile: 99.9,
|
||||||
|
bins: 512,
|
||||||
|
},
|
||||||
|
127.0,
|
||||||
|
);
|
||||||
|
let batch: Vec<f32> = (1..=100).map(|i| i as f32 * 0.01).collect();
|
||||||
|
c.observe(&batch);
|
||||||
|
c.observe(&batch);
|
||||||
|
let s = c.scale().unwrap();
|
||||||
|
// Effective amax should be close to 1.0 (upper end of the batch).
|
||||||
|
assert!(s > 0.0 && s <= 2.0 / 127.0, "scale {s} out of expected range");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,671 @@
|
|||||||
|
/// Draft model distillation loss for speculative decoding training.
|
||||||
|
///
|
||||||
|
/// Implements KL divergence between target (teacher) and draft (student) logit
|
||||||
|
/// distributions, with optional hard-label cross-entropy mixing.
|
||||||
|
///
|
||||||
|
/// # Loss Formula
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// L_distill = distill_weight * KL(p_target || p_draft)
|
||||||
|
/// + ce_weight * CE(hard_labels, p_draft)
|
||||||
|
///
|
||||||
|
/// KL(p || q) = Σ p * log(p / q) = Σ p * (log p - log q)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Temperature scaling softens both distributions before KL:
|
||||||
|
/// `p_soft = softmax(logits / T)`
|
||||||
|
|
||||||
|
// ── Configuration ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Configuration for draft distillation training.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DraftDistillConfig {
|
||||||
|
/// Temperature for softening logits (higher = softer, default 1.0).
|
||||||
|
pub temperature: f32,
|
||||||
|
/// Weight for distillation KL loss vs hard-label CE (default 0.9).
|
||||||
|
pub distill_weight: f32,
|
||||||
|
/// Weight for hard-label CE loss (default 0.1).
|
||||||
|
pub ce_weight: f32,
|
||||||
|
/// Vocabulary size.
|
||||||
|
pub vocab_size: usize,
|
||||||
|
/// Whether to normalize by sequence length.
|
||||||
|
pub normalize_by_length: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DraftDistillConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
temperature: 1.0,
|
||||||
|
distill_weight: 0.9,
|
||||||
|
ce_weight: 0.1,
|
||||||
|
vocab_size: 32000,
|
||||||
|
normalize_by_length: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Result types ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Output of the distillation loss computation.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DistillLossResult {
|
||||||
|
/// Total combined loss (`distill_weight * kl + ce_weight * ce`).
|
||||||
|
pub total_loss: f32,
|
||||||
|
/// KL divergence component.
|
||||||
|
pub kl_loss: f32,
|
||||||
|
/// Cross-entropy component.
|
||||||
|
pub ce_loss: f32,
|
||||||
|
/// Mean acceptance probability estimate (top-1 overlap between draft and target).
|
||||||
|
pub acceptance_estimate: f32,
|
||||||
|
/// Number of tokens processed.
|
||||||
|
pub num_tokens: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core numerics ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Numerically stable log-softmax over a slice.
|
||||||
|
///
|
||||||
|
/// Divides each element by `temperature` before computing:
|
||||||
|
/// `log_softmax(x_i) = (x_i/T) - max(x/T) - log Σ exp((x_j/T) - max(x/T))`
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if `logits` is empty or if `temperature` is zero or negative.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use rtx_transformers::training::draft_distill::log_softmax;
|
||||||
|
/// let v = log_softmax(&[1.0_f32, 2.0, 3.0], 1.0);
|
||||||
|
/// let exp_sum: f32 = v.iter().map(|x| x.exp()).sum();
|
||||||
|
/// assert!((exp_sum - 1.0).abs() < 1e-6);
|
||||||
|
/// ```
|
||||||
|
pub fn log_softmax(logits: &[f32], temperature: f32) -> Vec<f32> {
|
||||||
|
assert!(!logits.is_empty(), "log_softmax: logits must not be empty");
|
||||||
|
assert!(temperature > 0.0, "log_softmax: temperature must be positive");
|
||||||
|
|
||||||
|
let scaled: Vec<f32> = logits.iter().map(|&x| x / temperature).collect();
|
||||||
|
let max_val = scaled.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
||||||
|
let shifted: Vec<f32> = scaled.iter().map(|&x| x - max_val).collect();
|
||||||
|
let log_sum_exp = shifted.iter().map(|&x| x.exp()).sum::<f32>().ln();
|
||||||
|
shifted.iter().map(|&x| x - log_sum_exp).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Numerically stable softmax over a slice.
|
||||||
|
///
|
||||||
|
/// Equivalent to `exp(log_softmax(logits, temperature))` but avoids a second
|
||||||
|
/// pass through the data.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use rtx_transformers::training::draft_distill::softmax;
|
||||||
|
/// let p = softmax(&[1.0_f32, 2.0, 3.0], 1.0);
|
||||||
|
/// let sum: f32 = p.iter().sum();
|
||||||
|
/// assert!((sum - 1.0).abs() < 1e-6);
|
||||||
|
/// ```
|
||||||
|
pub fn softmax(logits: &[f32], temperature: f32) -> Vec<f32> {
|
||||||
|
log_softmax(logits, temperature)
|
||||||
|
.iter()
|
||||||
|
.map(|&x| x.exp())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// KL(p ‖ q) = Σ p · (log p − log q), summed over vocab.
|
||||||
|
///
|
||||||
|
/// Both `p` (probability distribution, sums to 1) and `log_q` (log-probabilities)
|
||||||
|
/// must have the same length.
|
||||||
|
///
|
||||||
|
/// Terms where `p ≈ 0` are treated as 0 (0 · log 0 = 0 by convention).
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use rtx_transformers::training::draft_distill::{kl_divergence, log_softmax, softmax};
|
||||||
|
/// let logits = vec![1.0_f32, 2.0, 3.0];
|
||||||
|
/// let p = softmax(&logits, 1.0);
|
||||||
|
/// let log_q = log_softmax(&logits, 1.0);
|
||||||
|
/// let kl = kl_divergence(&p, &log_q);
|
||||||
|
/// assert!(kl.abs() < 1e-5); // KL(p||p) == 0
|
||||||
|
/// ```
|
||||||
|
pub fn kl_divergence(p: &[f32], log_q: &[f32]) -> f32 {
|
||||||
|
assert_eq!(p.len(), log_q.len(), "kl_divergence: p and log_q must have the same length");
|
||||||
|
p.iter()
|
||||||
|
.zip(log_q.iter())
|
||||||
|
.map(|(&pi, &log_qi)| {
|
||||||
|
if pi <= 0.0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
pi * (pi.ln() - log_qi)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token acceptance rate estimate.
|
||||||
|
///
|
||||||
|
/// Returns the probability that the draft model's top-1 token matches the target
|
||||||
|
/// model's top-1 token, computed as `min(p_draft[argmax], p_target[argmax_target])`.
|
||||||
|
///
|
||||||
|
/// This approximates the speculative decoding acceptance probability for
|
||||||
|
/// greedy-sampled tokens.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use rtx_transformers::training::draft_distill::token_acceptance_estimate;
|
||||||
|
/// let mut p = vec![0.01_f32; 10];
|
||||||
|
/// p[3] = 0.91; // both models peak at token 3
|
||||||
|
/// let accept = token_acceptance_estimate(&p, &p);
|
||||||
|
/// assert!(accept > 0.8);
|
||||||
|
/// ```
|
||||||
|
pub fn token_acceptance_estimate(draft_probs: &[f32], target_probs: &[f32]) -> f32 {
|
||||||
|
assert_eq!(
|
||||||
|
draft_probs.len(),
|
||||||
|
target_probs.len(),
|
||||||
|
"token_acceptance_estimate: slices must have the same length"
|
||||||
|
);
|
||||||
|
// argmax of target distribution
|
||||||
|
let target_top1 = target_probs
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Acceptance probability for speculative decoding (greedy target):
|
||||||
|
// min(1, p_draft[target_top1] / p_target[target_top1])
|
||||||
|
// Simplified as min(p_draft[t], p_target[t]) / p_target[t]
|
||||||
|
// = min(p_draft[t] / p_target[t], 1.0)
|
||||||
|
let p_t = target_probs[target_top1];
|
||||||
|
let p_d = draft_probs[target_top1];
|
||||||
|
if p_t <= 0.0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(p_d / p_t).min(1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main loss computation ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Compute draft distillation loss for a batch of sequence positions.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `draft_logits` — flattened `[seq_len × vocab_size]` raw logits from draft model
|
||||||
|
/// * `target_logits` — flattened `[seq_len × vocab_size]` raw logits from target model
|
||||||
|
/// * `hard_labels` — ground-truth token ids, `len == seq_len`
|
||||||
|
/// * `config` — distillation hyper-parameters
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// * If `draft_logits.len() != target_logits.len()`
|
||||||
|
/// * If `hard_labels.len() * config.vocab_size != draft_logits.len()`
|
||||||
|
/// * If any `hard_labels[i] >= config.vocab_size`
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use rtx_transformers::training::draft_distill::{DraftDistillConfig, distill_loss_step};
|
||||||
|
/// let config = DraftDistillConfig { vocab_size: 4, ..Default::default() };
|
||||||
|
/// let logits = vec![1.0_f32, 2.0, 3.0, 0.5];
|
||||||
|
/// let result = distill_loss_step(&logits, &logits, &[2u32], &config);
|
||||||
|
/// assert!(result.kl_loss.abs() < 1e-5);
|
||||||
|
/// ```
|
||||||
|
pub fn distill_loss_step(
|
||||||
|
draft_logits: &[f32],
|
||||||
|
target_logits: &[f32],
|
||||||
|
hard_labels: &[u32],
|
||||||
|
config: &DraftDistillConfig,
|
||||||
|
) -> DistillLossResult {
|
||||||
|
assert_eq!(
|
||||||
|
draft_logits.len(),
|
||||||
|
target_logits.len(),
|
||||||
|
"distill_loss_step: draft and target logit buffers must be the same length"
|
||||||
|
);
|
||||||
|
let seq_len = hard_labels.len();
|
||||||
|
let vocab = config.vocab_size;
|
||||||
|
assert_eq!(
|
||||||
|
seq_len * vocab,
|
||||||
|
draft_logits.len(),
|
||||||
|
"distill_loss_step: hard_labels.len() * vocab_size must equal logit buffer length"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut total_kl = 0.0_f32;
|
||||||
|
let mut total_ce = 0.0_f32;
|
||||||
|
let mut total_accept = 0.0_f32;
|
||||||
|
|
||||||
|
for (t, &label) in hard_labels.iter().enumerate() {
|
||||||
|
assert!(
|
||||||
|
(label as usize) < vocab,
|
||||||
|
"distill_loss_step: hard_label {} out of range (vocab_size={})",
|
||||||
|
label,
|
||||||
|
vocab
|
||||||
|
);
|
||||||
|
let start = t * vocab;
|
||||||
|
let end = start + vocab;
|
||||||
|
let d_slice = &draft_logits[start..end];
|
||||||
|
let tgt_slice = &target_logits[start..end];
|
||||||
|
|
||||||
|
let target_probs = softmax(tgt_slice, config.temperature);
|
||||||
|
let draft_log_probs = log_softmax(d_slice, config.temperature);
|
||||||
|
let draft_probs: Vec<f32> = draft_log_probs.iter().map(|&x| x.exp()).collect();
|
||||||
|
|
||||||
|
// KL(p_target || p_draft) = Σ p_target * (log p_target - log p_draft)
|
||||||
|
let kl = kl_divergence(&target_probs, &draft_log_probs);
|
||||||
|
total_kl += kl;
|
||||||
|
|
||||||
|
// Hard-label CE: -log p_draft[label]
|
||||||
|
let ce = -draft_log_probs[label as usize];
|
||||||
|
total_ce += ce;
|
||||||
|
|
||||||
|
total_accept += token_acceptance_estimate(&draft_probs, &target_probs);
|
||||||
|
}
|
||||||
|
|
||||||
|
let n = if config.normalize_by_length && seq_len > 0 {
|
||||||
|
seq_len as f32
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let kl_loss = total_kl / n;
|
||||||
|
let ce_loss = total_ce / n;
|
||||||
|
let acceptance_estimate = if seq_len > 0 {
|
||||||
|
total_accept / seq_len as f32
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let total_loss = config.distill_weight * kl_loss + config.ce_weight * ce_loss;
|
||||||
|
|
||||||
|
DistillLossResult {
|
||||||
|
total_loss,
|
||||||
|
kl_loss,
|
||||||
|
ce_loss,
|
||||||
|
acceptance_estimate,
|
||||||
|
num_tokens: seq_len,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Accumulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Accumulates distillation loss across multiple steps for an epoch.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use rtx_transformers::training::draft_distill::{
|
||||||
|
/// DraftDistillConfig, DistillAccumulator, DistillLossResult,
|
||||||
|
/// };
|
||||||
|
/// let mut acc = DistillAccumulator::new(DraftDistillConfig::default());
|
||||||
|
/// let result = DistillLossResult {
|
||||||
|
/// total_loss: 0.5, kl_loss: 0.4, ce_loss: 0.6,
|
||||||
|
/// acceptance_estimate: 0.7, num_tokens: 8,
|
||||||
|
/// };
|
||||||
|
/// acc.add(&result);
|
||||||
|
/// let summary = acc.summary();
|
||||||
|
/// assert!((summary.kl_loss - 0.4).abs() < 1e-6);
|
||||||
|
/// ```
|
||||||
|
pub struct DistillAccumulator {
|
||||||
|
config: DraftDistillConfig,
|
||||||
|
total_kl: f32,
|
||||||
|
total_ce: f32,
|
||||||
|
total_accept: f32,
|
||||||
|
steps: usize,
|
||||||
|
total_tokens: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DistillAccumulator {
|
||||||
|
/// Create a new accumulator with the given configuration.
|
||||||
|
pub fn new(config: DraftDistillConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
total_kl: 0.0,
|
||||||
|
total_ce: 0.0,
|
||||||
|
total_accept: 0.0,
|
||||||
|
steps: 0,
|
||||||
|
total_tokens: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add one step's result.
|
||||||
|
pub fn add(&mut self, result: &DistillLossResult) {
|
||||||
|
self.total_kl += result.kl_loss;
|
||||||
|
self.total_ce += result.ce_loss;
|
||||||
|
self.total_accept += result.acceptance_estimate;
|
||||||
|
self.steps += 1;
|
||||||
|
self.total_tokens += result.num_tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Average stats across all accumulated steps.
|
||||||
|
///
|
||||||
|
/// Returns zero-valued result if no steps have been accumulated.
|
||||||
|
pub fn summary(&self) -> DistillLossResult {
|
||||||
|
if self.steps == 0 {
|
||||||
|
return DistillLossResult {
|
||||||
|
total_loss: 0.0,
|
||||||
|
kl_loss: 0.0,
|
||||||
|
ce_loss: 0.0,
|
||||||
|
acceptance_estimate: 0.0,
|
||||||
|
num_tokens: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let n = self.steps as f32;
|
||||||
|
let kl_loss = self.total_kl / n;
|
||||||
|
let ce_loss = self.total_ce / n;
|
||||||
|
let acceptance_estimate = self.total_accept / n;
|
||||||
|
let total_loss =
|
||||||
|
self.config.distill_weight * kl_loss + self.config.ce_weight * ce_loss;
|
||||||
|
DistillLossResult {
|
||||||
|
total_loss,
|
||||||
|
kl_loss,
|
||||||
|
ce_loss,
|
||||||
|
acceptance_estimate,
|
||||||
|
num_tokens: self.total_tokens / self.steps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset for next epoch.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.total_kl = 0.0;
|
||||||
|
self.total_ce = 0.0;
|
||||||
|
self.total_accept = 0.0;
|
||||||
|
self.steps = 0;
|
||||||
|
self.total_tokens = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const EPS: f32 = 1e-5;
|
||||||
|
|
||||||
|
// ── softmax ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_softmax_sums_to_one() {
|
||||||
|
let logits = vec![1.0_f32, 2.0, 3.0, 0.5, -1.0];
|
||||||
|
let p = softmax(&logits, 1.0);
|
||||||
|
let sum: f32 = p.iter().sum();
|
||||||
|
assert!(
|
||||||
|
(sum - 1.0).abs() < 1e-6,
|
||||||
|
"softmax outputs must sum to 1.0, got {sum}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_softmax_temperature_flattens() {
|
||||||
|
// At high temperature the distribution should be more uniform.
|
||||||
|
let logits = vec![10.0_f32, 1.0, 1.0, 1.0];
|
||||||
|
let p_sharp = softmax(&logits, 1.0);
|
||||||
|
let p_flat = softmax(&logits, 100.0);
|
||||||
|
|
||||||
|
// Standard deviation of flat should be smaller than sharp
|
||||||
|
let std_dev = |v: &[f32]| {
|
||||||
|
let mean = v.iter().sum::<f32>() / v.len() as f32;
|
||||||
|
(v.iter().map(|x| (x - mean).powi(2)).sum::<f32>() / v.len() as f32).sqrt()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
std_dev(&p_flat) < std_dev(&p_sharp),
|
||||||
|
"high temperature must produce a flatter distribution"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── log_softmax ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_log_softmax_matches_log_of_softmax() {
|
||||||
|
let logits = vec![2.0_f32, -1.0, 0.5, 3.0, 1.0];
|
||||||
|
let log_sm = log_softmax(&logits, 1.0);
|
||||||
|
let sm_then_log: Vec<f32> = softmax(&logits, 1.0).iter().map(|x| x.ln()).collect();
|
||||||
|
|
||||||
|
for (a, b) in log_sm.iter().zip(sm_then_log.iter()) {
|
||||||
|
assert!(
|
||||||
|
(a - b).abs() < EPS,
|
||||||
|
"log_softmax mismatch: {a} vs log(softmax)={b}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── kl_divergence ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_kl_divergence_identical_dists_zero() {
|
||||||
|
let logits = vec![1.0_f32, 2.0, 3.0, 0.5];
|
||||||
|
let p = softmax(&logits, 1.0);
|
||||||
|
let log_p = log_softmax(&logits, 1.0);
|
||||||
|
let kl = kl_divergence(&p, &log_p);
|
||||||
|
assert!(
|
||||||
|
kl.abs() < EPS,
|
||||||
|
"KL(p||p) must be ≈ 0, got {kl}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_kl_divergence_positive() {
|
||||||
|
// KL divergence is always non-negative (Gibbs' inequality).
|
||||||
|
let p_logits = vec![1.0_f32, 2.0, 3.0, 0.5];
|
||||||
|
let q_logits = vec![0.5_f32, 1.5, 2.5, 2.0];
|
||||||
|
let p = softmax(&p_logits, 1.0);
|
||||||
|
let log_q = log_softmax(&q_logits, 1.0);
|
||||||
|
let kl = kl_divergence(&p, &log_q);
|
||||||
|
assert!(kl >= 0.0, "KL divergence must be non-negative, got {kl}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── token_acceptance_estimate ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_token_acceptance_top1_match() {
|
||||||
|
// Both distributions peak at the same token → acceptance should be 1.0.
|
||||||
|
let mut p = vec![0.01_f32; 10];
|
||||||
|
p[5] = 0.91;
|
||||||
|
let accept = token_acceptance_estimate(&p, &p);
|
||||||
|
assert!(
|
||||||
|
accept > 0.9,
|
||||||
|
"acceptance when top-1 tokens match should be high, got {accept}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_token_acceptance_no_match() {
|
||||||
|
// Draft peaks at token 0, target peaks at token 9.
|
||||||
|
let mut draft = vec![0.01_f32; 10];
|
||||||
|
draft[0] = 0.91;
|
||||||
|
let mut target = vec![0.01_f32; 10];
|
||||||
|
target[9] = 0.91;
|
||||||
|
|
||||||
|
let accept = token_acceptance_estimate(&draft, &target);
|
||||||
|
// Draft probability at target's top-1 (token 9) is 0.01; target prob is 0.91
|
||||||
|
// acceptance = min(0.01 / 0.91, 1.0) ≈ 0.011
|
||||||
|
assert!(
|
||||||
|
accept < 0.05,
|
||||||
|
"acceptance when top-1 tokens differ should be low, got {accept}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── distill_loss_step ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_distill_loss_identical_logits_zero_kl() {
|
||||||
|
let vocab = 8_usize;
|
||||||
|
let seq_len = 3_usize;
|
||||||
|
let logits: Vec<f32> = (0..seq_len * vocab).map(|i| i as f32 * 0.1).collect();
|
||||||
|
let labels: Vec<u32> = (0..seq_len as u32).collect();
|
||||||
|
let config = DraftDistillConfig {
|
||||||
|
vocab_size: vocab,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let result = distill_loss_step(&logits, &logits, &labels, &config);
|
||||||
|
assert!(
|
||||||
|
result.kl_loss.abs() < EPS,
|
||||||
|
"KL loss must be ≈ 0 when draft == target, got {}",
|
||||||
|
result.kl_loss
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_distill_loss_ce_component() {
|
||||||
|
// total_loss must equal distill_weight*kl + ce_weight*ce
|
||||||
|
let vocab = 4_usize;
|
||||||
|
let draft_logits = vec![1.0_f32, 2.0, 3.0, 0.5];
|
||||||
|
let target_logits = vec![0.5_f32, 1.5, 2.5, 2.0];
|
||||||
|
let labels = vec![2u32];
|
||||||
|
let config = DraftDistillConfig {
|
||||||
|
vocab_size: vocab,
|
||||||
|
distill_weight: 0.8,
|
||||||
|
ce_weight: 0.2,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let result = distill_loss_step(&draft_logits, &target_logits, &labels, &config);
|
||||||
|
let expected = config.distill_weight * result.kl_loss + config.ce_weight * result.ce_loss;
|
||||||
|
assert!(
|
||||||
|
(result.total_loss - expected).abs() < EPS,
|
||||||
|
"total_loss must equal w_kl*kl + w_ce*ce: {} vs {}",
|
||||||
|
result.total_loss,
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_distill_loss_result_total_positive() {
|
||||||
|
// When draft differs from target, total loss must be strictly positive.
|
||||||
|
let vocab = 4_usize;
|
||||||
|
let draft_logits = vec![3.0_f32, 0.1, 0.1, 0.1]; // peaks at 0
|
||||||
|
let target_logits = vec![0.1_f32, 0.1, 0.1, 3.0]; // peaks at 3
|
||||||
|
let labels = vec![3u32];
|
||||||
|
let config = DraftDistillConfig {
|
||||||
|
vocab_size: vocab,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let result = distill_loss_step(&draft_logits, &target_logits, &labels, &config);
|
||||||
|
assert!(
|
||||||
|
result.total_loss > 0.0,
|
||||||
|
"total_loss must be > 0 when draft ≠ target, got {}",
|
||||||
|
result.total_loss
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DistillAccumulator ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_accumulator_averages_correctly() {
|
||||||
|
let config = DraftDistillConfig {
|
||||||
|
vocab_size: 4,
|
||||||
|
distill_weight: 0.9,
|
||||||
|
ce_weight: 0.1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut acc = DistillAccumulator::new(config);
|
||||||
|
|
||||||
|
let r1 = DistillLossResult {
|
||||||
|
total_loss: 0.5,
|
||||||
|
kl_loss: 0.4,
|
||||||
|
ce_loss: 0.6,
|
||||||
|
acceptance_estimate: 0.7,
|
||||||
|
num_tokens: 4,
|
||||||
|
};
|
||||||
|
let r2 = DistillLossResult {
|
||||||
|
total_loss: 0.3,
|
||||||
|
kl_loss: 0.2,
|
||||||
|
ce_loss: 0.4,
|
||||||
|
acceptance_estimate: 0.9,
|
||||||
|
num_tokens: 6,
|
||||||
|
};
|
||||||
|
acc.add(&r1);
|
||||||
|
acc.add(&r2);
|
||||||
|
|
||||||
|
let summary = acc.summary();
|
||||||
|
// Mean kl = (0.4 + 0.2) / 2 = 0.3
|
||||||
|
assert!(
|
||||||
|
(summary.kl_loss - 0.3).abs() < EPS,
|
||||||
|
"mean kl_loss should be 0.3, got {}",
|
||||||
|
summary.kl_loss
|
||||||
|
);
|
||||||
|
// Mean ce = (0.6 + 0.4) / 2 = 0.5
|
||||||
|
assert!(
|
||||||
|
(summary.ce_loss - 0.5).abs() < EPS,
|
||||||
|
"mean ce_loss should be 0.5, got {}",
|
||||||
|
summary.ce_loss
|
||||||
|
);
|
||||||
|
// Mean acceptance = (0.7 + 0.9) / 2 = 0.8
|
||||||
|
assert!(
|
||||||
|
(summary.acceptance_estimate - 0.8).abs() < EPS,
|
||||||
|
"mean acceptance should be 0.8, got {}",
|
||||||
|
summary.acceptance_estimate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_accumulator_reset() {
|
||||||
|
let mut acc = DistillAccumulator::new(DraftDistillConfig::default());
|
||||||
|
let r = DistillLossResult {
|
||||||
|
total_loss: 1.0,
|
||||||
|
kl_loss: 0.8,
|
||||||
|
ce_loss: 0.2,
|
||||||
|
acceptance_estimate: 0.5,
|
||||||
|
num_tokens: 8,
|
||||||
|
};
|
||||||
|
acc.add(&r);
|
||||||
|
acc.reset();
|
||||||
|
|
||||||
|
assert_eq!(acc.steps, 0, "steps must be 0 after reset");
|
||||||
|
let summary = acc.summary();
|
||||||
|
assert_eq!(summary.num_tokens, 0, "num_tokens must be 0 after reset");
|
||||||
|
assert!(summary.total_loss.abs() < EPS, "total_loss must be 0 after reset");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── normalize_by_length ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_distill_loss_normalize_by_length() {
|
||||||
|
// A sequence of length N should yield the same per-token loss as length 1.
|
||||||
|
let vocab = 4_usize;
|
||||||
|
let single_draft = vec![1.0_f32, 0.1, 0.1, 0.1];
|
||||||
|
let single_target = vec![0.1_f32, 0.1, 0.1, 1.0];
|
||||||
|
let single_label = vec![3u32];
|
||||||
|
|
||||||
|
// Repeat the same token N times
|
||||||
|
let n: usize = 5;
|
||||||
|
let multi_draft: Vec<f32> = single_draft
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.cycle()
|
||||||
|
.take(n * vocab)
|
||||||
|
.collect();
|
||||||
|
let multi_target: Vec<f32> = single_target
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.cycle()
|
||||||
|
.take(n * vocab)
|
||||||
|
.collect();
|
||||||
|
let multi_label: Vec<u32> = vec![3u32; n];
|
||||||
|
|
||||||
|
let config = DraftDistillConfig {
|
||||||
|
vocab_size: vocab,
|
||||||
|
normalize_by_length: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let single_result =
|
||||||
|
distill_loss_step(&single_draft, &single_target, &single_label, &config);
|
||||||
|
let multi_result =
|
||||||
|
distill_loss_step(&multi_draft, &multi_target, &multi_label, &config);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(single_result.kl_loss - multi_result.kl_loss).abs() < EPS,
|
||||||
|
"per-token KL loss must be independent of sequence length \
|
||||||
|
when normalize_by_length=true: single={} multi={}",
|
||||||
|
single_result.kl_loss,
|
||||||
|
multi_result.kl_loss
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(single_result.ce_loss - multi_result.ce_loss).abs() < EPS,
|
||||||
|
"per-token CE loss must be independent of sequence length: \
|
||||||
|
single={} multi={}",
|
||||||
|
single_result.ce_loss,
|
||||||
|
multi_result.ce_loss
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,679 @@
|
|||||||
|
//! Gradient Noise Scale (GNS) Monitor
|
||||||
|
//!
|
||||||
|
//! Implements the gradient noise scale estimator from McCandlish et al. (2018),
|
||||||
|
//! "An Empirical Model of Large-Batch Training" (arXiv:1812.06162).
|
||||||
|
//!
|
||||||
|
//! GNS measures the ratio of gradient noise to gradient signal:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! B_noise = tr(Σ) / |g|²
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! where Σ is the gradient covariance and g is the mean gradient. A large
|
||||||
|
//! `B_noise` means the current batch size is well below the critical batch
|
||||||
|
//! size — adding more samples still improves gradient quality. A small
|
||||||
|
//! `B_noise` means the batch is already at or above the critical size and
|
||||||
|
//! further scaling gives diminishing returns.
|
||||||
|
//!
|
||||||
|
//! ## Two-Point Estimator
|
||||||
|
//!
|
||||||
|
//! In practice GNS is computed from two gradient norms measured at a large
|
||||||
|
//! batch B and a small batch S in the same step:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! G² = (B·‖g_B‖² − S·‖g_S‖²) / (B − S)
|
||||||
|
//! S² = (‖g_S‖² − ‖g_B‖²) / (1/S − 1/B)
|
||||||
|
//! B_noise = S² / G²
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Single-Estimate Mode
|
||||||
|
//!
|
||||||
|
//! When only one gradient norm per step is available, `update_single` uses a
|
||||||
|
//! Welford online variance over the running EMA to approximate the noise
|
||||||
|
//! component. This is less accurate but requires no extra forward pass.
|
||||||
|
//!
|
||||||
|
//! ## Example
|
||||||
|
//!
|
||||||
|
//! ```rust
|
||||||
|
//! use rtx_transformers::training::gradient_noise_scale::{GradientNoiseScale, GnsTracker};
|
||||||
|
//!
|
||||||
|
//! // Two-point estimation
|
||||||
|
//! let mut gns = GradientNoiseScale::new(0.99, 256);
|
||||||
|
//! let estimate = gns.update(1.2, 1.8, 32);
|
||||||
|
//! assert!(estimate.noise_scale >= 0.0);
|
||||||
|
//!
|
||||||
|
//! // Tracker with history
|
||||||
|
//! let mut tracker = GnsTracker::new(0.99, 256, 100);
|
||||||
|
//! let est = tracker.update(1.2, 1.8, 32);
|
||||||
|
//! println!("GNS: {:.2}, recommended batch: {}", est.noise_scale, est.recommended_batch_size);
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GnsEstimate
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Result from one GNS estimation step.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GnsEstimate {
|
||||||
|
/// Estimated noise scale (B_noise = S² / G²).
|
||||||
|
pub noise_scale: f32,
|
||||||
|
/// Current EMA of squared gradient norm (‖g_B‖²).
|
||||||
|
pub ema_sq_norm: f32,
|
||||||
|
/// Recommended batch size clamped to a caller-supplied maximum.
|
||||||
|
/// Computed as `min(noise_scale * current_batch_size, max_batch)` — but
|
||||||
|
/// here `max_batch` defaults to `usize::MAX` so the field carries the
|
||||||
|
/// uncapped recommended size. Use [`GradientNoiseScale::recommended_batch_size`]
|
||||||
|
/// to apply a cap.
|
||||||
|
pub recommended_batch_size: usize,
|
||||||
|
/// True when `current_batch_size < 0.5 * noise_scale` — batch is well
|
||||||
|
/// below the critical size; scaling up is beneficial.
|
||||||
|
pub should_increase_batch: bool,
|
||||||
|
/// True when `current_batch_size > 2.0 * noise_scale` — batch is above
|
||||||
|
/// the critical size; scaling down saves compute without hurting quality.
|
||||||
|
pub should_decrease_batch: bool,
|
||||||
|
/// Number of update steps completed (1-indexed after first update).
|
||||||
|
pub step: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GradientNoiseScale
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Exponential moving-average Gradient Noise Scale estimator.
|
||||||
|
///
|
||||||
|
/// Tracks two EMA quantities derived from per-step gradient statistics and
|
||||||
|
/// exposes the noise-to-signal ratio B_noise that indicates the critical
|
||||||
|
/// batch size (McCandlish et al. 2018).
|
||||||
|
///
|
||||||
|
/// # Fields
|
||||||
|
///
|
||||||
|
/// * `momentum` — EMA decay factor (e.g. 0.99 for slow smoothing).
|
||||||
|
/// * `current_batch_size` — The large-batch size B used in the two-point
|
||||||
|
/// estimator and in the batch-size recommendations.
|
||||||
|
/// * `ema_sq_grad_norm` — EMA of ‖g‖² (squared L2 norm of the large-batch
|
||||||
|
/// gradient).
|
||||||
|
/// * `ema_grad_norm_sq` — EMA of the signal estimate G²; approximated via
|
||||||
|
/// the two-point formula or via a Welford online estimator in single mode.
|
||||||
|
/// * `step` — number of calls to `update` or `update_single`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GradientNoiseScale {
|
||||||
|
/// EMA momentum coefficient. Higher values mean slower adaptation.
|
||||||
|
pub momentum: f32,
|
||||||
|
/// The large-batch size B used when calling `update`.
|
||||||
|
pub current_batch_size: usize,
|
||||||
|
|
||||||
|
// Internal EMA state
|
||||||
|
ema_sq_grad_norm: f32, // EMA of ‖g_B‖²
|
||||||
|
ema_grad_norm_sq: f32, // EMA of the G² signal component
|
||||||
|
|
||||||
|
// Welford state for single-estimate mode
|
||||||
|
welford_mean: f32,
|
||||||
|
welford_m2: f32,
|
||||||
|
|
||||||
|
step: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GradientNoiseScale {
|
||||||
|
/// Construct a new estimator.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `momentum` — EMA decay, typically 0.99. Must be in (0, 1).
|
||||||
|
/// * `initial_batch_size` — The large batch size B.
|
||||||
|
pub fn new(momentum: f32, initial_batch_size: usize) -> Self {
|
||||||
|
assert!(
|
||||||
|
momentum > 0.0 && momentum < 1.0,
|
||||||
|
"momentum must be in (0, 1), got {momentum}"
|
||||||
|
);
|
||||||
|
assert!(initial_batch_size > 0, "batch size must be > 0");
|
||||||
|
Self {
|
||||||
|
momentum,
|
||||||
|
current_batch_size: initial_batch_size,
|
||||||
|
ema_sq_grad_norm: 0.0,
|
||||||
|
ema_grad_norm_sq: 0.0,
|
||||||
|
welford_mean: 0.0,
|
||||||
|
welford_m2: 0.0,
|
||||||
|
step: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Public update methods
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Two-point GNS estimator using large-batch and small-batch norms.
|
||||||
|
///
|
||||||
|
/// Uses the McCandlish et al. two-point formula:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// G² = (B·‖g_B‖² − S·‖g_S‖²) / (B − S)
|
||||||
|
/// S² = (‖g_S‖² − ‖g_B‖²) / (1/S − 1/B)
|
||||||
|
/// B_noise = S² / G²
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Negative intermediate estimates are clamped to 0.0.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `large_batch_norm` — ‖g_B‖, the gradient L2 norm from the full
|
||||||
|
/// batch of size `self.current_batch_size`.
|
||||||
|
/// * `small_batch_norm` — ‖g_S‖, the gradient L2 norm from a random
|
||||||
|
/// mini-batch of size `small_bs`.
|
||||||
|
/// * `small_bs` — mini-batch size S. Must be < `self.current_batch_size`.
|
||||||
|
pub fn update(
|
||||||
|
&mut self,
|
||||||
|
large_batch_norm: f32,
|
||||||
|
small_batch_norm: f32,
|
||||||
|
small_bs: usize,
|
||||||
|
) -> GnsEstimate {
|
||||||
|
assert!(
|
||||||
|
small_bs < self.current_batch_size,
|
||||||
|
"small_bs ({small_bs}) must be < current_batch_size ({})",
|
||||||
|
self.current_batch_size
|
||||||
|
);
|
||||||
|
assert!(small_bs > 0, "small_bs must be > 0");
|
||||||
|
|
||||||
|
let b = self.current_batch_size as f32;
|
||||||
|
let s = small_bs as f32;
|
||||||
|
let g_b_sq = large_batch_norm * large_batch_norm;
|
||||||
|
let g_s_sq = small_batch_norm * small_batch_norm;
|
||||||
|
|
||||||
|
// Signal estimate G²: numerator can be negative (noisy measurement),
|
||||||
|
// clamp to 0.
|
||||||
|
let g_sq_num = b * g_b_sq - s * g_s_sq;
|
||||||
|
let g_sq_den = b - s;
|
||||||
|
let g_sq = (g_sq_num / g_sq_den).max(0.0);
|
||||||
|
|
||||||
|
// Noise estimate S²: denominator (1/s − 1/b) is always positive since s < b.
|
||||||
|
let inv_s = 1.0 / s;
|
||||||
|
let inv_b = 1.0 / b;
|
||||||
|
let noise_num = g_s_sq - g_b_sq;
|
||||||
|
let noise_den = inv_s - inv_b;
|
||||||
|
let s_sq = (noise_num / noise_den).max(0.0);
|
||||||
|
|
||||||
|
// Raw B_noise for this step
|
||||||
|
let raw_noise_scale = if g_sq > f32::EPSILON {
|
||||||
|
s_sq / g_sq
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply EMA updates
|
||||||
|
let alpha = 1.0 - self.momentum;
|
||||||
|
self.ema_sq_grad_norm = self.momentum * self.ema_sq_grad_norm + alpha * g_b_sq;
|
||||||
|
self.ema_grad_norm_sq = self.momentum * self.ema_grad_norm_sq + alpha * raw_noise_scale;
|
||||||
|
|
||||||
|
self.step += 1;
|
||||||
|
|
||||||
|
self.build_estimate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-estimate GNS update using only the current batch gradient norm.
|
||||||
|
///
|
||||||
|
/// Less accurate than the two-point estimator; uses a Welford online
|
||||||
|
/// algorithm to maintain a running variance of the EMA-smoothed gradient
|
||||||
|
/// norms. The variance approximates the noise component.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `grad_norm` — ‖g‖, the gradient L2 norm for the current step.
|
||||||
|
pub fn update_single(&mut self, grad_norm: f32) -> GnsEstimate {
|
||||||
|
let g_sq = grad_norm * grad_norm;
|
||||||
|
let alpha = 1.0 - self.momentum;
|
||||||
|
|
||||||
|
// Welford online mean/variance over the squared norms
|
||||||
|
self.step += 1;
|
||||||
|
let delta = g_sq - self.welford_mean;
|
||||||
|
self.welford_mean += delta / self.step as f32;
|
||||||
|
let delta2 = g_sq - self.welford_mean;
|
||||||
|
self.welford_m2 += delta * delta2;
|
||||||
|
|
||||||
|
let variance = if self.step > 1 {
|
||||||
|
self.welford_m2 / (self.step as f32 - 1.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Noise approximation: variance / mean (coefficient of variation)
|
||||||
|
let raw_noise_scale = if self.welford_mean > f32::EPSILON {
|
||||||
|
variance / self.welford_mean
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// EMA updates
|
||||||
|
self.ema_sq_grad_norm = self.momentum * self.ema_sq_grad_norm + alpha * g_sq;
|
||||||
|
self.ema_grad_norm_sq = self.momentum * self.ema_grad_norm_sq + alpha * raw_noise_scale;
|
||||||
|
|
||||||
|
self.build_estimate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Query methods
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Current noise scale estimate (S² / G²).
|
||||||
|
///
|
||||||
|
/// Returns 0.0 until at least one update has been performed.
|
||||||
|
#[must_use]
|
||||||
|
pub fn noise_scale(&self) -> f32 {
|
||||||
|
self.ema_grad_norm_sq.max(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recommended batch size given the current noise scale, capped at `max_batch`.
|
||||||
|
///
|
||||||
|
/// The recommendation is `noise_scale() * current_batch_size`, rounded to
|
||||||
|
/// the nearest integer and clamped to `[1, max_batch]`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn recommended_batch_size(&self, max_batch: usize) -> usize {
|
||||||
|
let ns = self.noise_scale();
|
||||||
|
if ns <= 0.0 {
|
||||||
|
return self.current_batch_size.min(max_batch);
|
||||||
|
}
|
||||||
|
let raw = (ns * self.current_batch_size as f32).round() as usize;
|
||||||
|
raw.max(1).min(max_batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset all state to initial zeros. `current_batch_size` and `momentum`
|
||||||
|
/// are preserved.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.ema_sq_grad_norm = 0.0;
|
||||||
|
self.ema_grad_norm_sq = 0.0;
|
||||||
|
self.welford_mean = 0.0;
|
||||||
|
self.welford_m2 = 0.0;
|
||||||
|
self.step = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of update steps observed so far.
|
||||||
|
#[must_use]
|
||||||
|
pub fn steps(&self) -> usize {
|
||||||
|
self.step
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn build_estimate(&self) -> GnsEstimate {
|
||||||
|
let noise_scale = self.noise_scale();
|
||||||
|
let b = self.current_batch_size as f32;
|
||||||
|
|
||||||
|
let should_increase_batch = b < 0.5 * noise_scale;
|
||||||
|
let should_decrease_batch = b > 2.0 * noise_scale && noise_scale > 0.0;
|
||||||
|
|
||||||
|
let recommended_batch_size = self.recommended_batch_size(usize::MAX);
|
||||||
|
|
||||||
|
GnsEstimate {
|
||||||
|
noise_scale,
|
||||||
|
ema_sq_norm: self.ema_sq_grad_norm,
|
||||||
|
recommended_batch_size,
|
||||||
|
should_increase_batch,
|
||||||
|
should_decrease_batch,
|
||||||
|
step: self.step,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GnsTracker
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Tracks GNS history and provides trend analysis.
|
||||||
|
///
|
||||||
|
/// Wraps [`GradientNoiseScale`] and maintains a bounded ring-buffer of
|
||||||
|
/// [`GnsEstimate`] values for trend analysis.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// use rtx_transformers::training::gradient_noise_scale::GnsTracker;
|
||||||
|
///
|
||||||
|
/// let mut tracker = GnsTracker::new(0.99, 256, 50);
|
||||||
|
/// for _ in 0..10 {
|
||||||
|
/// tracker.update(1.0, 1.5, 32);
|
||||||
|
/// }
|
||||||
|
/// println!("avg GNS last 5: {:.2}", tracker.avg_noise_scale(5));
|
||||||
|
/// ```
|
||||||
|
pub struct GnsTracker {
|
||||||
|
gns: GradientNoiseScale,
|
||||||
|
history: VecDeque<GnsEstimate>,
|
||||||
|
max_history: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GnsTracker {
|
||||||
|
/// Construct a new tracker.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `momentum` — EMA decay (see [`GradientNoiseScale::new`]).
|
||||||
|
/// * `initial_batch_size` — large batch size B.
|
||||||
|
/// * `max_history` — maximum number of estimates stored in the ring-buffer.
|
||||||
|
pub fn new(momentum: f32, initial_batch_size: usize, max_history: usize) -> Self {
|
||||||
|
assert!(max_history > 0, "max_history must be > 0");
|
||||||
|
Self {
|
||||||
|
gns: GradientNoiseScale::new(momentum, initial_batch_size),
|
||||||
|
history: VecDeque::with_capacity(max_history.min(1024)),
|
||||||
|
max_history,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two-point update. Pushes the new estimate into the history ring-buffer.
|
||||||
|
pub fn update(&mut self, large_norm: f32, small_norm: f32, small_bs: usize) -> &GnsEstimate {
|
||||||
|
let est = self.gns.update(large_norm, small_norm, small_bs);
|
||||||
|
self.push(est);
|
||||||
|
self.history.back().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-estimate update. Pushes the new estimate into the history ring-buffer.
|
||||||
|
pub fn update_single(&mut self, grad_norm: f32) -> &GnsEstimate {
|
||||||
|
let est = self.gns.update_single(grad_norm);
|
||||||
|
self.push(est);
|
||||||
|
self.history.back().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mean noise scale over the last `last_n` estimates.
|
||||||
|
///
|
||||||
|
/// If fewer than `last_n` estimates exist, averages all available.
|
||||||
|
/// Returns 0.0 if history is empty.
|
||||||
|
#[must_use]
|
||||||
|
pub fn avg_noise_scale(&self, last_n: usize) -> f32 {
|
||||||
|
if self.history.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let count = last_n.min(self.history.len());
|
||||||
|
let skip = self.history.len() - count;
|
||||||
|
let sum: f32 = self.history.iter().skip(skip).map(|e| e.noise_scale).sum();
|
||||||
|
sum / count as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the noise scale has an increasing trend over the stored
|
||||||
|
/// history (i.e. the second half average exceeds the first half average).
|
||||||
|
///
|
||||||
|
/// Returns false if fewer than 2 estimates are stored.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_noise_scale_increasing(&self) -> bool {
|
||||||
|
let n = self.history.len();
|
||||||
|
if n < 2 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mid = n / 2;
|
||||||
|
let first_half: f32 = self.history.iter().take(mid).map(|e| e.noise_scale).sum::<f32>()
|
||||||
|
/ mid as f32;
|
||||||
|
let second_half: f32 = self
|
||||||
|
.history
|
||||||
|
.iter()
|
||||||
|
.skip(mid)
|
||||||
|
.map(|e| e.noise_scale)
|
||||||
|
.sum::<f32>()
|
||||||
|
/ (n - mid) as f32;
|
||||||
|
second_half > first_half
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable access to the full history ring-buffer.
|
||||||
|
#[must_use]
|
||||||
|
pub fn history(&self) -> &VecDeque<GnsEstimate> {
|
||||||
|
&self.history
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most recent estimate, or `None` if no updates have been performed.
|
||||||
|
#[must_use]
|
||||||
|
pub fn latest(&self) -> Option<&GnsEstimate> {
|
||||||
|
self.history.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn push(&mut self, est: GnsEstimate) {
|
||||||
|
if self.history.len() == self.max_history {
|
||||||
|
self.history.pop_front();
|
||||||
|
}
|
||||||
|
self.history.push_back(est);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// GradientNoiseScale tests
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_starts_at_zero_steps() {
|
||||||
|
let gns = GradientNoiseScale::new(0.99, 256);
|
||||||
|
assert_eq!(gns.steps(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_increments_steps() {
|
||||||
|
let mut gns = GradientNoiseScale::new(0.99, 256);
|
||||||
|
gns.update(1.0, 1.5, 32);
|
||||||
|
assert_eq!(gns.steps(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_identical_norms_noise_scale_near_zero() {
|
||||||
|
// When g_B == g_S the noise numerator (g_S² − g_B²) == 0, so B_noise == 0.
|
||||||
|
let mut gns = GradientNoiseScale::new(0.99, 256);
|
||||||
|
for _ in 0..50 {
|
||||||
|
gns.update(1.0, 1.0, 32);
|
||||||
|
}
|
||||||
|
// After many identical steps the EMA of raw_noise_scale (0) stays near 0.
|
||||||
|
assert!(
|
||||||
|
gns.noise_scale() < 1e-3,
|
||||||
|
"expected near-zero noise scale, got {}",
|
||||||
|
gns.noise_scale()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_noise_scale_nonnegative() {
|
||||||
|
let mut gns = GradientNoiseScale::new(0.99, 256);
|
||||||
|
// Adversarial: large batch norm > small batch norm (inverted order)
|
||||||
|
for i in 0..20 {
|
||||||
|
let large = 2.0 + i as f32 * 0.1;
|
||||||
|
let small = 0.5 + i as f32 * 0.05;
|
||||||
|
gns.update(large, small, 32);
|
||||||
|
}
|
||||||
|
assert!(gns.noise_scale() >= 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_recommended_batch_capped_at_max() {
|
||||||
|
let mut gns = GradientNoiseScale::new(0.5, 256);
|
||||||
|
// Drive noise scale very high by making small_norm << large_norm
|
||||||
|
for _ in 0..100 {
|
||||||
|
gns.update(0.1, 10.0, 32);
|
||||||
|
}
|
||||||
|
let capped = gns.recommended_batch_size(512);
|
||||||
|
assert!(capped <= 512, "expected cap at 512, got {capped}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_increase_when_below_half_noise() {
|
||||||
|
// Construct a GNS whose noise_scale will be very large (>>2*B).
|
||||||
|
// We seed the EMA directly by many updates with tiny large_norm and huge small_norm.
|
||||||
|
let mut gns = GradientNoiseScale::new(0.5, 16);
|
||||||
|
// small_norm >> large_norm → noise large, signal tiny → B_noise large
|
||||||
|
for _ in 0..200 {
|
||||||
|
gns.update(0.01, 100.0, 4);
|
||||||
|
}
|
||||||
|
let ns = gns.noise_scale();
|
||||||
|
// Only test should_increase if noise scale is actually high enough
|
||||||
|
if ns > 2.0 * 16.0 {
|
||||||
|
let est = gns.update(0.01, 100.0, 4);
|
||||||
|
assert!(
|
||||||
|
est.should_increase_batch,
|
||||||
|
"should_increase expected (ns={ns}, B=16)"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// The EMA dampened it; just verify the flag logic is consistent
|
||||||
|
let est = gns.update(0.01, 100.0, 4);
|
||||||
|
let b = gns.current_batch_size as f32;
|
||||||
|
assert_eq!(est.should_increase_batch, b < 0.5 * est.noise_scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_decrease_when_above_double_noise() {
|
||||||
|
let mut gns = GradientNoiseScale::new(0.5, 256);
|
||||||
|
// large_norm >> small_norm → noise small, signal large → B_noise tiny
|
||||||
|
for _ in 0..200 {
|
||||||
|
gns.update(100.0, 0.01, 4);
|
||||||
|
}
|
||||||
|
let ns = gns.noise_scale();
|
||||||
|
let b = gns.current_batch_size as f32;
|
||||||
|
if ns > 0.0 && b > 2.0 * ns {
|
||||||
|
let est = gns.update(100.0, 0.01, 4);
|
||||||
|
assert!(
|
||||||
|
est.should_decrease_batch,
|
||||||
|
"should_decrease expected (ns={ns}, B={b})"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Verify logic consistency
|
||||||
|
let est = gns.update(100.0, 0.01, 4);
|
||||||
|
assert_eq!(
|
||||||
|
est.should_decrease_batch,
|
||||||
|
est.noise_scale > 0.0 && b > 2.0 * est.noise_scale
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ema_smoothing() {
|
||||||
|
// With high momentum the EMA should change slowly between two
|
||||||
|
// very different gradient norms.
|
||||||
|
let mut gns = GradientNoiseScale::new(0.99, 256);
|
||||||
|
// Warm up with small norms
|
||||||
|
for _ in 0..50 {
|
||||||
|
gns.update(0.5, 0.8, 32);
|
||||||
|
}
|
||||||
|
let before = gns.ema_sq_grad_norm;
|
||||||
|
// Sudden spike
|
||||||
|
gns.update(10.0, 12.0, 32);
|
||||||
|
let after = gns.ema_sq_grad_norm;
|
||||||
|
// High momentum (0.99) → small step; EMA moves towards 100 by only 1%
|
||||||
|
let expected_delta = (1.0 - 0.99) * (100.0 - before);
|
||||||
|
let actual_delta = after - before;
|
||||||
|
assert!(
|
||||||
|
(actual_delta - expected_delta).abs() < 1.0,
|
||||||
|
"EMA step too large: delta={actual_delta}, expected~{expected_delta}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reset_clears_state() {
|
||||||
|
let mut gns = GradientNoiseScale::new(0.99, 256);
|
||||||
|
for _ in 0..10 {
|
||||||
|
gns.update(1.0, 1.5, 32);
|
||||||
|
}
|
||||||
|
assert!(gns.steps() > 0);
|
||||||
|
gns.reset();
|
||||||
|
assert_eq!(gns.steps(), 0);
|
||||||
|
assert_eq!(gns.noise_scale(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_single_update_increments_steps() {
|
||||||
|
let mut gns = GradientNoiseScale::new(0.99, 256);
|
||||||
|
gns.update_single(1.0);
|
||||||
|
assert_eq!(gns.steps(), 1);
|
||||||
|
gns.update_single(1.2);
|
||||||
|
assert_eq!(gns.steps(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_single_noise_scale_nonnegative() {
|
||||||
|
let mut gns = GradientNoiseScale::new(0.99, 256);
|
||||||
|
for i in 0..30 {
|
||||||
|
gns.update_single(i as f32 * 0.1 + 0.5);
|
||||||
|
}
|
||||||
|
assert!(gns.noise_scale() >= 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// GnsTracker tests
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tracker_history_bounded() {
|
||||||
|
let mut tracker = GnsTracker::new(0.99, 256, 5);
|
||||||
|
for _ in 0..10 {
|
||||||
|
tracker.update(1.0, 1.5, 32);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
tracker.history().len(),
|
||||||
|
5,
|
||||||
|
"history should be capped at max_history=5"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tracker_avg_noise_scale() {
|
||||||
|
let mut tracker = GnsTracker::new(0.99, 256, 100);
|
||||||
|
// Feed 6 updates; manually record the last 3 noise scales to compute expected avg
|
||||||
|
// We use update_single to control inputs deterministically
|
||||||
|
let mut gns_ref = GradientNoiseScale::new(0.99, 256);
|
||||||
|
for i in 0..3 {
|
||||||
|
let norm = 0.5 + i as f32 * 0.2;
|
||||||
|
gns_ref.update_single(norm);
|
||||||
|
tracker.update_single(norm);
|
||||||
|
}
|
||||||
|
// Now 3 more — capture their noise scales
|
||||||
|
let mut last3_sum = 0.0_f32;
|
||||||
|
for i in 0..3 {
|
||||||
|
let norm = 1.5 + i as f32 * 0.3;
|
||||||
|
let est = tracker.update_single(norm);
|
||||||
|
last3_sum += est.noise_scale;
|
||||||
|
}
|
||||||
|
let expected = last3_sum / 3.0;
|
||||||
|
let got = tracker.avg_noise_scale(3);
|
||||||
|
assert!(
|
||||||
|
(got - expected).abs() < 1e-5,
|
||||||
|
"avg mismatch: got {got}, expected {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tracker_latest_returns_most_recent() {
|
||||||
|
let mut tracker = GnsTracker::new(0.99, 256, 50);
|
||||||
|
let norms = [1.0_f32, 1.2, 0.8, 1.5, 0.9];
|
||||||
|
let mut last_step = 0;
|
||||||
|
for &n in &norms {
|
||||||
|
let est = tracker.update_single(n);
|
||||||
|
last_step = est.step;
|
||||||
|
}
|
||||||
|
let latest = tracker.latest().expect("history should be non-empty");
|
||||||
|
assert_eq!(
|
||||||
|
latest.step, last_step,
|
||||||
|
"latest() step mismatch: got {}, expected {last_step}",
|
||||||
|
latest.step
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tracker_avg_empty_returns_zero() {
|
||||||
|
let tracker = GnsTracker::new(0.99, 256, 50);
|
||||||
|
assert_eq!(tracker.avg_noise_scale(5), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tracker_is_noise_scale_increasing() {
|
||||||
|
let mut tracker = GnsTracker::new(0.5, 256, 100);
|
||||||
|
// Feed increasing grad norms — Welford variance grows, so noise scale should rise
|
||||||
|
for i in 1..=20 {
|
||||||
|
tracker.update_single(i as f32 * 0.5);
|
||||||
|
}
|
||||||
|
// At least verify the method returns a bool without panic
|
||||||
|
let _ = tracker.is_noise_scale_increasing();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
//! Training infrastructure for transformers
|
//! Training infrastructure for transformers
|
||||||
|
|
||||||
|
pub mod draft_distill;
|
||||||
pub mod comprehensive_integration_test;
|
pub mod comprehensive_integration_test;
|
||||||
pub mod end_to_end_training_example;
|
pub mod end_to_end_training_example;
|
||||||
|
pub mod gradient_noise_scale;
|
||||||
pub mod length_bucketing;
|
pub mod length_bucketing;
|
||||||
pub mod gradient_accumulation_enhanced;
|
pub mod gradient_accumulation_enhanced;
|
||||||
pub mod gradient_accumulator;
|
pub mod gradient_accumulator;
|
||||||
@@ -45,6 +47,11 @@ pub use transformer_trainer::{
|
|||||||
pub use length_bucketing::{
|
pub use length_bucketing::{
|
||||||
BucketConfig, LengthBatch, LengthGroupedSampler, naive_padding_ratio, pack_into_batch,
|
BucketConfig, LengthBatch, LengthGroupedSampler, naive_padding_ratio, pack_into_batch,
|
||||||
};
|
};
|
||||||
|
pub use draft_distill::{
|
||||||
|
DistillAccumulator, DistillLossResult, DraftDistillConfig,
|
||||||
|
distill_loss_step, kl_divergence, log_softmax, softmax, token_acceptance_estimate,
|
||||||
|
};
|
||||||
|
pub use gradient_noise_scale::{GnsEstimate, GnsTracker, GradientNoiseScale};
|
||||||
|
|
||||||
/// Training state structure
|
/// Training state structure
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
Reference in New Issue
Block a user