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

- 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:
Omar Sobh
2026-06-27 05:37:19 +00:00
co-authored by Claude Sonnet 4.6
parent 3ec300af6b
commit 924c237096
5 changed files with 1972 additions and 0 deletions
@@ -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)]